Skip to content

Organization Management API

Organization hierarchy, settings, SSO configuration, proxy policies, and session reporting. Requires ADMIN_TOKEN.

Bases: BaseAPIClient

API client for Silo organization management operations.

This class provides methods for managing organizations in the Authentic8 Silo platform, including creating, retrieving, and managing organizational hierarchies.

The organization management API allows you to: - Get detailed information about organizations - Retrieve organizational hierarchies and sub-organizations - Create new organizations with parent-child relationships - Generate session reports and usage analytics - Manage proxy policies for organizations

Example

from silo_sdk import OrgManagementAPI, load_config config = load_config() orgs = OrgManagementAPI(config)

Get organization details

org_info = orgs.get_org("my_organization") print(f"Organization: {org_info['org_name']}")

Create a sub-organization

new_org = orgs.create_org( ... org_name="sub_org", ... parent_org_name="my_organization", ... vanity_url="sub-org" ... )

Source code in silo_sdk/management/org_api.py
  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
 924
 925
 926
 927
 928
 929
 930
 931
 932
 933
 934
 935
 936
 937
 938
 939
 940
 941
 942
 943
 944
 945
 946
 947
 948
 949
 950
 951
 952
 953
 954
 955
 956
 957
 958
 959
 960
 961
 962
 963
 964
 965
 966
 967
 968
 969
 970
 971
 972
 973
 974
 975
 976
 977
 978
 979
 980
 981
 982
 983
 984
 985
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
1632
1633
1634
1635
1636
1637
1638
1639
1640
1641
1642
1643
1644
1645
1646
1647
1648
1649
1650
1651
1652
1653
1654
1655
1656
1657
1658
1659
1660
1661
1662
1663
1664
1665
1666
1667
1668
1669
1670
1671
1672
1673
1674
1675
1676
1677
1678
1679
1680
1681
1682
1683
1684
1685
1686
1687
1688
1689
1690
1691
1692
1693
1694
1695
1696
1697
1698
1699
1700
1701
1702
1703
1704
1705
1706
1707
1708
1709
1710
1711
1712
class OrgManagementAPI(BaseAPIClient):
    """API client for Silo organization management operations.

    This class provides methods for managing organizations in the Authentic8 Silo platform,
    including creating, retrieving, and managing organizational hierarchies.

    The organization management API allows you to:
    - Get detailed information about organizations
    - Retrieve organizational hierarchies and sub-organizations
    - Create new organizations with parent-child relationships
    - Generate session reports and usage analytics
    - Manage proxy policies for organizations

    Example:
        >>> from silo_sdk import OrgManagementAPI, load_config
        >>> config = load_config()
        >>> orgs = OrgManagementAPI(config)
        >>>
        >>> # Get organization details
        >>> org_info = orgs.get_org("my_organization")
        >>> print(f"Organization: {org_info['org_name']}")
        >>>
        >>> # Create a sub-organization
        >>> new_org = orgs.create_org(
        ...     org_name="sub_org",
        ...     parent_org_name="my_organization",
        ...     vanity_url="sub-org"
        ... )
    """

    def __init__(self, config: dict[str, Any]) -> None:
        """Initialize the organization 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, ["ADMIN_TOKEN"])

        self.auth_token = config["ADMIN_TOKEN"]
        self.logger.info("Initialized OrgManagementAPI client")

    @api_tag(MethodType.API_COMMAND, api_command="org.get")
    def get_org(
        self,
        org_name: str | None = None,
        org_id: str | None = None,
    ) -> dict[str, Any]:
        """Get information about an organization.

        Retrieves comprehensive information about a specific organization including
        its configuration, settings, user counts, and other metadata.

        At least one of ``org_name`` or ``org_id`` must be provided. When only
        ``org_id`` is supplied the API resolves the organization by its unique
        identifier — useful when you have an ID but not the name.

        Args:
            org_name: Silo organization API name to retrieve (e.g., ``"acme_corp"``).
                This is the organization's API identifier, **not** the SSO vanity URL.
                Do not confuse with ``ORG_VANITY_URL`` config value (which is for SSO URLs).
            org_id: Organization ID. Can be used alone for lookup or together
                with ``org_name`` for additional validation.

        Note:
            Wire format parameter naming:

            - Org management commands use ``"org_name"`` in the wire format
            - User management and log extraction commands use ``"org"`` instead
            - ``org_name`` is the organization's API identifier, distinct from the
              ``ORG_VANITY_URL`` config setting (which is for SSO login URLs)

            Wire format structure::

                {
                    "command": "org.get",
                    "org_name": "acme_corp",  # ← flat, not nested under "data"
                    "org_id": "org_abc123"  # ← optional, or used alone for ID lookup
                }

        Returns:
            Dictionary containing organization details including:

            - ``org_name``: Organization name
            - ``org_id``: Unique organization identifier
            - ``created_ts``: Organization creation timestamp
            - ``user_count``: Number of users in the organization
            - ``settings``: Organization configuration settings
            - ``parent_org``: Parent organization information (if applicable)

        Raises:
            OrgManagementAPIError: If the API request fails
            ValidationError: If neither org_name nor org_id is provided

        Example:
            >>> # Look up by name
            >>> org_info = api.get_org("acme_corp")
            >>> print(f"Organization: {org_info['org_name']}")
            >>>
            >>> # Look up by ID
            >>> org_info = api.get_org(org_id="513f1bdb...")
            >>> print(f"Organization: {org_info['org_name']}")
        """
        if not org_name and not org_id:
            raise ValidationError("At least one of org_name or org_id must be provided")

        if org_name is not None and (not org_name or not isinstance(org_name, str)):
            raise ValidationError("org_name must be a non-empty string when provided")

        if org_id is not None and (not org_id or not isinstance(org_id, str)):
            raise ValidationError("org_id must be a non-empty string when provided")

        payload_data: dict[str, Any] = {
            "command": "org.get",
        }

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

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

        payload = [payload_data]

        lookup_key = org_name or org_id
        try:
            self.logger.debug("Getting organization details for: %s", lookup_key)
            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 organization details successfully")
                return result

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

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

    @api_tag(MethodType.CONVENIENCE, wraps=["get_org"])
    def resolve_org(self, org_name: str) -> dict[str, Any]:
        """Resolve an org name to its canonical path and identifiers.

        Calls ``get_org`` to look up the organization. If the name is ambiguous
        (the API returns an error listing candidates), those candidates are
        returned in the ``candidates`` field instead of raising an exception.

        Args:
            org_name: Organization name to resolve. May be a partial name —
                the API will return candidates if it is ambiguous.

        Returns:
            Dictionary with resolved org info::

                {
                    "org_name": "MyOrg/SubOrg",
                    "org_id": "abc123",
                    "parent_org_name": "MyOrg",
                    "candidates": []
                }

            When the name is ambiguous, ``candidates`` contains the list of
            matching org names returned by the API and ``org_name``,
            ``org_id``, and ``parent_org_name`` will be empty strings.

        Raises:
            ValidationError: If ``org_name`` is empty.
            OrgManagementAPIError: If the API request fails with a
                non-ambiguity error.
        """
        if not isinstance(org_name, str) or not org_name.strip():
            raise ValidationError("org_name must be a non-empty string")

        payload = [{"command": "org.get", "org_name": org_name}]

        try:
            self.logger.debug("Resolving organization: %s", org_name)
            response = self._make_api_request("POST", "api/", self.auth_token, payload)

            if isinstance(response, list) and len(response) > 1:
                result_entry = response[1]
                if isinstance(result_entry, dict):
                    # Check for an error indicating ambiguity
                    error_val = result_entry.get("error", "")
                    if error_val and isinstance(error_val, str):
                        candidates = result_entry.get("candidates", [])
                        if not candidates and error_val:
                            candidates = [error_val]
                        return {
                            "org_name": "",
                            "org_id": "",
                            "parent_org_name": "",
                            "candidates": candidates,
                        }

                    data = result_entry.get("result", {})
                    if isinstance(data, dict):
                        return {
                            "org_name": data.get("org_name", org_name),
                            "org_id": data.get("org_id", ""),
                            "parent_org_name": data.get("parent_org_name", ""),
                            "candidates": [],
                        }

            return {
                "org_name": org_name,
                "org_id": "",
                "parent_org_name": "",
                "candidates": [],
            }

        except Exception as e:
            if isinstance(e, (OrgManagementAPIError, ValidationError)):
                raise
            raise OrgManagementAPIError(f"Failed to resolve organization: {e}") from e

    @api_tag(MethodType.API_COMMAND, api_command="org.get_children")
    def get_org_children(
        self, org_name: str, org_id: str | None = None
    ) -> list[dict[str, Any]]:
        """Get list of all the sub-organizations of an organization.

        Retrieves a list of all child organizations under the specified parent
        organization, including their basic information and hierarchy details.

        Args:
            org_name: Silo organization name to get children for
            org_id: Optional ID of the Silo organization for additional validation

        Returns:
            List of dictionaries containing sub-organization details including:
            - org_name: Child organization name
            - org_id: Child organization ID
            - created_ts: Creation timestamp
            - user_count: Number of users in child org
            - depth: Hierarchy depth level

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

        Example:
            >>> children = api.get_org_children("parent_org")
            >>> for child in children:
            ...     print(f"Child org: {child['org_name']} ({child['user_count']} users)")
        """
        if not org_name or not isinstance(org_name, str) or not org_name.strip():
            raise ValidationError("Organization name must be a non-empty string")

        payload_data = {
            "command": "org.get_children",
            "org_name": org_name,
        }

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

        payload = [payload_data]

        try:
            self.logger.debug("Getting child organizations for: %s", org_name)
            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 child organizations for %s", len(result), org_name
                )
                return result

            self.logger.warning(
                "Unexpected result format for get_org_children: %s", result
            )
            return []

        except Exception as e:
            if isinstance(e, OrgManagementAPIError):
                raise
            raise OrgManagementAPIError(
                f"Failed to get organization children: {e}"
            ) from e

    @api_tag(MethodType.API_COMMAND, api_command="org.create")
    def create_org(
        self,
        org_name: str,
        vanity_url: str | None = None,
        parent_org_name: str | None = None,
        parent_org_id: str | None = None,
    ) -> dict[str, str]:
        """Create a new sub-organization in Silo.

        Creates a new organization as a child within the organizational hierarchy.
        ``org.create`` always produces a sub-organization — there is no API mechanism
        to create a true top-level org. The top of the hierarchy is the
        ``"Authentic8 Root of All"`` organization.

        When neither ``parent_org_name`` nor ``parent_org_id`` is provided, the API
        places the new org under the organization scope of the authenticating
        admin token. High-privilege tokens will create directly under
        ``"Authentic8 Root of All"``.

        ``vanity_url`` is optional for this command. It is **required** only for
        :meth:`create_partner_sso_config`, which creates an org with SSO pre-configured.

        Note:
            SCIM protocol integrations use ``org_name`` to map provisioned group names
            to Silo organizations. In that context the ``org_name`` value comes from
            the identity provider's group name.

        Args:
            org_name: Name for the new sub-organization.
            vanity_url: Optional vanity URL for the organization. Required only for
                SSO-enabled orgs (use :meth:`create_partner_sso_config` for those).
            parent_org_name: Name of the parent organization. If omitted (along with
                ``parent_org_id``), the admin token's org scope is used as the parent.
            parent_org_id: ID of the parent organization. Alternative to
                ``parent_org_name`` — use one or the other, not both.

        Returns:
            Dictionary containing the new organization's details including
            ``org_id``, ``org_name``, ``vanity_url``, and ``created_ts``.

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

        Example:
            >>> # Create a sub-org under an explicit parent
            >>> new_org = api.create_org(
            ...     org_name="engineering_team",
            ...     parent_org_name="my_company",
            ... )
            >>> print(f"Created: {new_org['org_name']} (id: {new_org['org_id']})")

            >>> # Omit parent — new org is placed under the token's org scope
            >>> new_org = api.create_org(org_name="auto_provisioned_group")
        """
        # Validate required parameters
        if not org_name or not isinstance(org_name, str) or not org_name.strip():
            raise ValidationError("Organization name must be a non-empty string")

        # Build payload
        payload_data: dict[str, Any] = {
            "command": "org.create",
            "org_name": org_name,
        }

        # Add optional parameters
        if vanity_url is not None:
            if not isinstance(vanity_url, str):
                raise ValidationError("Vanity URL must be a string")
            payload_data["vanity_url"] = vanity_url

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

        if parent_org_id is not None:
            if not isinstance(parent_org_id, str):
                raise ValidationError("Parent organization ID must be a string")
            payload_data["parent_org_id"] = parent_org_id

        payload = [payload_data]

        try:
            self.logger.debug("Creating organization: %s", org_name)
            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 created organization: %s", org_name)
                return result

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

        except Exception as e:
            if isinstance(e, OrgManagementAPIError):
                raise
            raise OrgManagementAPIError(f"Failed to create organization: {e}") from e

    @api_tag(MethodType.API_COMMAND, api_command="session_report")
    def get_session_report(
        self,
        org_name: str | None = None,
        start_date: str | None = None,
        end_date: str | None = None,
        username: str | None = None,
        org_id: str | None = None,
        user_id: str | None = None,
        hierarchy: bool = False,
    ) -> dict[str, Any]:
        """Get a high level report of sessions and isolation consumption.

        Generates a report of session statistics for the specified organization
        and time period.

        The API uses a priority waterfall for identifiers — only the first
        match is used: ``user_id`` > ``username`` > ``org_id`` > ``org_name``
        > ``hierarchy``. Provide only one per call. The ``hierarchy`` flag
        is **mutually exclusive** with other identifiers — the API silently
        ignores it when a higher-priority identifier is present.

        Args:
            org_name: Organization name to report on.
            start_date: Start date in ``MM-DD-YYYY`` format.
            end_date: End date in ``MM-DD-YYYY`` format.
            username: Username (email) to filter the report for a specific user.
            org_id: Organization ID to report on (alternative to ``org_name``).
            user_id: User ID to filter the report for a specific user
                (alternative to ``username``).
            hierarchy: When True, include all child orgs in the report.
                Cannot be combined with other identifiers — the API only
                honours this flag when no other identifier is provided.

        Returns:
            Dictionary containing session report data returned by the server.

        Raises:
            OrgManagementAPIError: If the API request fails
            ValidationError: If date format is invalid (including a
                syntactically plausible but wrong-format date, e.g. ISO
                ``YYYY-MM-DD`` instead of ``MM-DD-YYYY``) or multiple
                identifiers are provided

        Example:
            >>> report = api.get_session_report(
            ...     org_name="my_company",
            ...     start_date="01-01-2024",
            ...     end_date="01-31-2024",
            ... )
            >>> report = api.get_session_report(
            ...     org_id="7c5db979...",
            ...     start_date="01-01-2024",
            ...     end_date="01-31-2024",
            ... )
        """
        # Validate that at most one identifier is provided.
        # The API uses a priority waterfall — only the first match from
        # [user_id, username, org_id, org_name, :hierarchy] is sent to the
        # backend.  hierarchy is silently ignored when combined with another
        # identifier, so we reject the combination here to avoid confusion.
        identifiers = [
            ("user_id", user_id),
            ("username", username),
            ("org_id", org_id),
            ("org_name", org_name),
        ]
        provided = [name for name, val in identifiers if val is not None]
        if hierarchy and provided:
            raise ValidationError(
                "hierarchy cannot be combined with other identifiers "
                f"({', '.join(provided)}) — the API ignores hierarchy when "
                "a higher-priority identifier is present"
            )
        if len(provided) > 1:
            raise ValidationError(
                f"Only one identifier may be provided, got: {', '.join(provided)}"
            )

        # Validate date formats if provided.
        #
        # Strict parsing (rather than the old loose length/dash-count
        # check) is required so that a syntactically similar but wrong
        # format — most notably ISO 8601 (YYYY-MM-DD) — is rejected instead
        # of silently passing through to the wire, where it produces
        # wrong/undefined server-side behavior (see issue #44).
        if start_date is not None:
            if not isinstance(start_date, str):
                raise ValidationError(
                    "Start date must be a string in MM-DD-YYYY format"
                )
            if not _is_valid_mm_dd_yyyy(start_date):
                raise ValidationError(
                    f"Start date must be in MM-DD-YYYY format, got: {start_date!r}"
                )

        if end_date is not None:
            if not isinstance(end_date, str):
                raise ValidationError("End date must be a string in MM-DD-YYYY format")
            if not _is_valid_mm_dd_yyyy(end_date):
                raise ValidationError(
                    f"End date must be in MM-DD-YYYY format, got: {end_date!r}"
                )

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

        # Add the identifier (only one will be set)
        if user_id is not None:
            if not isinstance(user_id, str):
                raise ValidationError("user_id must be a string")
            payload_data["user_id"] = user_id

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

        if org_id is not None:
            if not isinstance(org_id, str):
                raise ValidationError("org_id must be a non-empty string")
            payload_data["org_id"] = org_id

        if org_name is not None:
            if not isinstance(org_name, str) or not org_name.strip():
                raise ValidationError("org_name must be a non-empty string")
            payload_data["org_name"] = org_name

        if hierarchy:
            payload_data[":hierarchy"] = True

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

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

        payload = [payload_data]

        try:
            self.logger.debug(
                "Generating session report for organization: %s", org_name or "default"
            )
            response = self._make_api_request("POST", "api/", self.auth_token, payload)

            result = self._extract_api_result(response)
            if isinstance(result, dict):
                if "error" in result:
                    raise OrgManagementAPIError(f"API error: {result['error']}")
                self.logger.info("Successfully generated session report")
                return result

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

        except Exception as e:
            if isinstance(e, OrgManagementAPIError):
                raise
            raise OrgManagementAPIError(f"Failed to get session report: {e}") from e

    @api_tag(MethodType.API_COMMAND, api_command="org.update")
    def update_org(
        self,
        org_name: str,
        new_org_name: str | None = None,
        vanity_url: str | None = None,
        parent_org_name: str | None = None,
        parent_org_id: str | None = None,
        org_id: str | None = None,
    ) -> dict[str, Any]:
        """Update an existing Silo organization.

        Updates various properties of an existing organization including its name,
        vanity URL, and parent organization relationships.

        Args:
            org_name: Current name of the organization to update
            new_org_name: New name for the organization
            vanity_url: New vanity URL for the organization
            parent_org_name: Name of the new parent organization
            parent_org_id: ID of the new parent organization
            org_id: Optional organization ID for additional validation

        Returns:
            Dictionary containing the updated organization details

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

        Example:
            >>> updated_org = api.update_org(
            ...     org_name="old_name",
            ...     new_org_name="new_name",
            ...     vanity_url="new-vanity",
            ...     parent_org_name="new_parent"
            ... )
        """
        if not org_name or not isinstance(org_name, str) or not org_name.strip():
            raise ValidationError("Organization name must be a non-empty string")

        payload_data = {
            "command": "org.update",
            "org_name": org_name,
        }

        # Add optional parameters
        if new_org_name is not None:
            if not isinstance(new_org_name, str):
                raise ValidationError("New organization name must be a string")
            payload_data["new_org_name"] = new_org_name

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

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

        if parent_org_id is not None:
            if not isinstance(parent_org_id, str):
                raise ValidationError("Parent organization ID must be a string")
            payload_data["parent_org_id"] = parent_org_id

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

        payload = [payload_data]

        try:
            self.logger.debug("Updating organization: %s", org_name)
            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 updated organization: %s", org_name)
                return result
            if isinstance(result, list) and len(result) > 0:
                org_details = result[0]
                if isinstance(org_details, dict):
                    self.logger.info("Successfully updated organization: %s", org_name)
                    return org_details

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

        except Exception as e:
            if isinstance(e, OrgManagementAPIError):
                raise
            raise OrgManagementAPIError(f"Failed to update organization: {e}") from e

    @api_tag(MethodType.API_COMMAND, api_command="org.delete")
    def delete_org(
        self,
        org_name: str,
    ) -> bool:
        """Delete a Silo organization.

        The server performs a soft delete: it stamps the organization with an
        expiration timestamp and queues a background task to remove it. The
        organization is immediately unusable and there is no API to undo this.

        Deletion is refused — as an error, not a ``False`` return — when the
        organization still contains users, when it still has sub-orgs, or when
        the calling admin's own permissions are anchored to it. Delete users
        and child orgs first, working leaf-first up the tree.

        Args:
            org_name: Name of the organization to delete

        Returns:
            True. This method never returns False: deletion is either confirmed
            by the server or an exception is raised. Treat
            ``OrgManagementAPIError`` as the failure signal — do not branch on
            the return value.

        Raises:
            OrgManagementAPIError: If the server refuses the deletion, the API
                request fails, or the response does not confirm the deletion
            ValidationError: If parameters are invalid

        Example:
            >>> try:
            ...     api.delete_org("org_to_delete")
            ...     print("Organization deleted successfully")
            ... except OrgManagementAPIError as exc:
            ...     print(f"Deletion failed: {exc}")
        """
        if not org_name or not isinstance(org_name, str) or not org_name.strip():
            raise ValidationError("Organization name must be a non-empty string")

        payload = [{"command": "org.delete", "org_name": org_name}]

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

            result = self._extract_api_result(response)
            if isinstance(result, dict):
                if result.get("deleted") == 1 and result.get("status") == 1:
                    self.logger.info("Successfully deleted organization: %s", org_name)
                    return True

            # Refusals and failures arrive as an error entry, which
            # _extract_api_result has already raised on. Anything else means the
            # server confirmed nothing, so surface it rather than reporting a
            # deletion that may or may not have happened.
            raise OrgManagementAPIError(
                f"Organization deletion was not confirmed by the server "
                f"(org_name={org_name!r}, result={result!r})"
            )

        except Exception as e:
            if isinstance(e, OrgManagementAPIError):
                raise
            raise OrgManagementAPIError(f"Failed to delete organization: {e}") from e

    @api_tag(MethodType.API_COMMAND, api_command="create_partner_sso_config")
    def create_partner_sso_config(
        self,
        org_name: str,
        vanity_url: str,
        idp_name: str,
        idp_login_url: str,
        idp_cert: list[dict[str, str]],
        parent_org_name: str | None = None,
        parent_org_id: str | None = None,
    ) -> dict[str, Any]:
        """Create a new organization with SSO settings.

        Creates a new organization and configures its Single Sign-On policy
        with the provided information. The organization will automatically
        have SSO enabled after creation.

        Args:
            org_name: Name for the new organization (creates a new org, not
                configures SSO on an existing one).
            vanity_url: Vanity URL for the organization (must be unique).
            idp_name: Identity Provider display name. Sent as ``"IdP_name"``
                in the wire format (capitalization differs from this param name).
            idp_login_url: SSO login URL. Sent as ``"IdP_login_URL"`` in the
                wire format (capitalization differs from this param name).
            idp_cert: List of certificate dicts with ``"name"``, ``"cert"``,
                and optional ``"notes"`` keys. Sent as ``"IdP_cert"`` in the
                wire format.
            parent_org_name: Name of the parent organization this org will be
                created under. Required in practice — omitting it creates the
                org at the root level, which is typically not permitted.
                Either ``parent_org_name`` or ``parent_org_id`` must be provided.
            parent_org_id: ID of the parent organization (alternative to
                ``parent_org_name``). Use one or the other, not both.

        Note:
            Wire format parameter name differences (all case-sensitive):

            - Python ``idp_name`` → wire ``"IdP_name"``
            - Python ``idp_login_url`` → wire ``"IdP_login_URL"``
            - Python ``idp_cert`` → wire ``"IdP_cert"``

            This creates a **new organization** with SSO pre-configured.
            It does not add SSO to an existing org.

        Returns:
            Dictionary containing the new organization's SSO configuration, including:

            - ``org_id``: Unique organization identifier
            - ``org_name``: Organization name
            - ``IdP_name``: Identity Provider name
            - ``IdP_login_URL``: IdP SSO login URL
            - ``IdP_cert``: List of IdP certificates (each with ``name``, ``cert``,
                optional ``notes``, and auto-generated ``upload_ts``)
            - ``SP_cert``: Silo Service Provider certificate
            - ``SP_entity_id``: SAML SP entity ID URL
            - ``a8_portal_url``: Silo Access Portal URL for the org
            - ``a8_postback_url``: SAML ACS (assertion consumer service) postback URL
            - ``partner_SSO_sign_cert``: Partner SSO signing certificate

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

        Example:
            >>> sso_org = api.create_partner_sso_config(
            ...     org_name="sso_org",
            ...     vanity_url="sso-org",
            ...     idp_name="MyIdP",
            ...     idp_login_url="https://idp.example.com/sso",
            ...     idp_cert=[{
            ...         "name": "idp_cert",
            ...         "cert": "-----BEGIN CERTIFICATE-----...",
            ...         "notes": "Main IdP certificate"
            ...     }],
            ...     parent_org_name="parent_org"
            ... )
        """
        # Validate required parameters
        if not org_name or not isinstance(org_name, str) or not org_name.strip():
            raise ValidationError("Organization name must be a non-empty string")

        if not vanity_url or not isinstance(vanity_url, str):
            raise ValidationError("Vanity URL must be a non-empty string")

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

        if not idp_login_url or not isinstance(idp_login_url, str):
            raise ValidationError("IdP login URL must be a non-empty string")

        if not isinstance(idp_cert, list) or not idp_cert:
            raise ValidationError("IdP certificates must be a non-empty list")

        # Validate certificate format
        for cert in idp_cert:
            if not isinstance(cert, dict) or "name" not in cert or "cert" not in cert:
                raise ValidationError(
                    "Each certificate must have 'name' and 'cert' fields"
                )

        payload_data = {
            "command": "create_partner_sso_config",
            "org_name": org_name,
            "vanity_url": vanity_url,
            "IdP_name": idp_name,
            "IdP_login_URL": idp_login_url,
            "IdP_cert": idp_cert,
        }

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

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

        payload = [payload_data]

        try:
            self.logger.debug("Creating organization with SSO: %s", org_name)
            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 created organization with SSO: %s", org_name
                )
                return result

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

        except Exception as e:
            if isinstance(e, OrgManagementAPIError):
                raise
            raise OrgManagementAPIError(
                f"Failed to create organization with SSO: {e}"
            ) from e

    @api_tag(MethodType.API_COMMAND, api_command="get_partner_sso_config")
    def get_partner_sso_config(
        self,
        org_name: str,
        org_id: str | None = None,
    ) -> dict[str, Any]:
        """Get SSO configuration for an organization.

        Retrieves the Single Sign-On configuration settings for the specified
        organization including IdP details and certificates.

        Args:
            org_name: Name of the organization
            org_id: Optional organization ID for additional validation

        Note:
            If SSO is disabled for the organization, the API returns an error
            response rather than an empty result. Callers checking whether SSO
            exists should catch ``OrgManagementAPIError`` and inspect the message
            for ``"SSO policy is disabled"`` or ``"not found"``.

        Returns:
            Dictionary containing SSO configuration details, including:

            - ``org_id``: Unique organization identifier
            - ``org_name``: Organization name
            - ``IdP_name``: Identity Provider name
            - ``IdP_login_URL``: IdP SSO login URL
            - ``IdP_cert``: List of IdP certificates (each with ``name``, ``cert``,
                optional ``notes``, and auto-generated ``upload_ts``)
            - ``SP_cert``: Silo Service Provider certificate
            - ``SP_entity_id``: SAML SP entity ID URL
            - ``a8_portal_url``: Silo Access Portal URL for the org
            - ``a8_postback_url``: SAML ACS (assertion consumer service) postback URL
            - ``partner_SSO_sign_cert``: Partner SSO signing certificate

        Raises:
            OrgManagementAPIError: If the API request fails or SSO is disabled
            ValidationError: If parameters are invalid

        Example:
            >>> sso_config = api.get_partner_sso_config("sso_org")
            >>> print(f"IdP: {sso_config['IdP_name']}")
            >>> print(f"Portal: {sso_config['a8_portal_url']}")
        """
        if not org_name or not isinstance(org_name, str) or not org_name.strip():
            raise ValidationError("Organization name must be a non-empty string")

        payload_data = {
            "command": "get_partner_sso_config",
            "org_name": org_name,
        }

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

        payload = [payload_data]

        try:
            self.logger.debug("Getting SSO config for organization: %s", org_name)
            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 SSO configuration successfully")
                return result

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

        except Exception as e:
            if isinstance(e, OrgManagementAPIError):
                raise
            raise OrgManagementAPIError(f"Failed to get SSO configuration: {e}") from e

    @api_tag(MethodType.API_COMMAND, api_command="enable_partner_sso_config")
    def enable_partner_sso_config(
        self,
        org_name: str,
        org_id: str | None = None,
    ) -> bool:
        """Enable SSO for an organization.

        Enables the Single Sign-On policy for the specified organization.

        Args:
            org_name: Name of the organization
            org_id: Optional organization ID for additional validation

        Returns:
            True if SSO was enabled successfully, False otherwise

        Raises:
            OrgManagementAPIError: If the API request fails
            ValidationError: If parameters are invalid
        """
        if not org_name or not isinstance(org_name, str) or not org_name.strip():
            raise ValidationError("Organization name must be a non-empty string")

        payload_data = {
            "command": "enable_partner_sso_config",
            "org_name": org_name,
        }

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

        payload = [payload_data]

        try:
            self.logger.debug("Enabling SSO for organization: %s", org_name)
            response = self._make_api_request("POST", "api/", self.auth_token, payload)

            result = self._extract_api_result(response)
            if result == 1:
                self.logger.info(
                    "Successfully enabled SSO for organization: %s", org_name
                )
                return True

            self.logger.warning("SSO enable may have failed: %s", result)
            return False

        except Exception as e:
            if isinstance(e, OrgManagementAPIError):
                raise
            raise OrgManagementAPIError(f"Failed to enable SSO: {e}") from e

    @api_tag(MethodType.API_COMMAND, api_command="disable_partner_sso_config")
    def disable_partner_sso_config(
        self,
        org_name: str,
        org_id: str | None = None,
    ) -> bool:
        """Disable SSO for an organization.

        Disables the Single Sign-On policy for the specified organization.
        The partner user will be suspended and will no longer be able to access
        the Silo platform.

        Args:
            org_name: Name of the organization
            org_id: Optional organization ID for additional validation

        Returns:
            True if SSO was disabled successfully, False otherwise

        Raises:
            OrgManagementAPIError: If the API request fails
            ValidationError: If parameters are invalid
        """
        if not org_name or not isinstance(org_name, str) or not org_name.strip():
            raise ValidationError("Organization name must be a non-empty string")

        payload_data = {
            "command": "disable_partner_sso_config",
            "org_name": org_name,
        }

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

        payload = [payload_data]

        try:
            self.logger.debug("Disabling SSO for organization: %s", org_name)
            response = self._make_api_request("POST", "api/", self.auth_token, payload)

            result = self._extract_api_result(response)
            if result == 1:
                self.logger.info(
                    "Successfully disabled SSO for organization: %s", org_name
                )
                return True

            self.logger.warning("SSO disable may have failed: %s", result)
            return False

        except Exception as e:
            if isinstance(e, OrgManagementAPIError):
                raise
            raise OrgManagementAPIError(f"Failed to disable SSO: {e}") from e

    @api_tag(MethodType.API_COMMAND, api_command="update_partner_sso_config")
    def update_partner_sso_config(
        self,
        org_name: str,
        org_id: str | None = None,
        new_org_name: str | None = None,
        parent_org_name: str | None = None,
        parent_org_id: str | None = None,
        vanity_url: str | None = None,
        idp_name: str | None = None,
        idp_login_url: str | None = None,
        idp_cert: list[dict[str, str]] | None = None,
    ) -> dict[str, Any]:
        """Update an existing SSO configuration, or add SSO to an existing org.

        Updates the Partner SSO configuration for an organization. All IdP fields
        are optional — supply only the fields you want to change.

        This method also works to **add** SSO to an existing organization that has
        no SSO configuration yet. In that case, pass all required IdP fields
        (``idp_name``, ``idp_login_url``, ``idp_cert``) along with ``vanity_url``.

        Args:
            org_name: Current name of the organization (case sensitive).
            org_id: Optional organization ID for additional validation.
            new_org_name: New name for the organization. Must be unique under
                the parent org.
            parent_org_name: New parent organization name (moves the org).
            parent_org_id: New parent organization ID (moves the org).
            vanity_url: New vanity URL. Must be unique across the system.
            idp_name: Identity Provider display name. Sent as ``"IdP_name"``
                in the wire format.
            idp_login_url: SSO login URL (also known as Single Sign-on URL).
                Sent as ``"IdP_login_URL"`` in the wire format.
            idp_cert: Full list of IdP certificates. To add a cert, append it
                to the array returned by ``get_partner_sso_config``. To remove
                a cert, omit it from the array. To update ``notes``, modify the
                entry in place. The ``upload_ts`` field is auto-generated and
                cannot be updated. Sent as ``"IdP_cert"`` in the wire format.

        Note:
            Wire format parameter name differences (all case-sensitive):

            - Python ``idp_name`` → wire ``"IdP_name"``
            - Python ``idp_login_url`` → wire ``"IdP_login_URL"``
            - Python ``idp_cert`` → wire ``"IdP_cert"``

            Certificate array management: the API replaces the entire cert list
            with what you send. Always fetch the current list from
            ``get_partner_sso_config`` first, modify it, then pass the full
            modified list here.

        Returns:
            Dictionary containing the updated SSO configuration, including:

            - ``org_id``: Unique organization identifier
            - ``org_name``: Organization name
            - ``IdP_name``: Identity Provider name
            - ``IdP_login_URL``: IdP SSO login URL
            - ``IdP_cert``: Updated list of IdP certificates
            - ``SP_cert``: Silo Service Provider certificate
            - ``SP_entity_id``: SAML SP entity ID URL
            - ``a8_portal_url``: Silo Access Portal URL for the org
            - ``a8_postback_url``: SAML ACS postback URL
            - ``partner_SSO_sign_cert``: Partner SSO signing certificate

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

        Example:
            >>> # Update IdP login URL only
            >>> updated = api.update_partner_sso_config(
            ...     org_name="sso_org",
            ...     idp_login_url="https://new-idp.example.com/sso",
            ... )
            >>>
            >>> # Add a new certificate without removing existing ones
            >>> current = api.get_partner_sso_config("sso_org")
            >>> new_certs = current["IdP_cert"] + [{
            ...     "name": "new_cert",
            ...     "cert": "-----BEGIN CERTIFICATE-----...",
            ... }]
            >>> api.update_partner_sso_config("sso_org", idp_cert=new_certs)
        """
        if not org_name or not isinstance(org_name, str) or not org_name.strip():
            raise ValidationError("Organization name must be a non-empty string")

        payload_data: dict[str, Any] = {
            "command": "update_partner_sso_config",
            "org_name": org_name,
        }

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

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

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

        if parent_org_id is not None:
            if not isinstance(parent_org_id, str):
                raise ValidationError("Parent organization ID must be a string")
            payload_data["parent_org_id"] = parent_org_id

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

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

        if idp_login_url is not None:
            if not isinstance(idp_login_url, str):
                raise ValidationError("IdP login URL must be a string")
            payload_data["IdP_login_URL"] = idp_login_url

        if idp_cert is not None:
            if not isinstance(idp_cert, list):
                raise ValidationError("IdP certificates must be a list")
            for cert in idp_cert:
                if (
                    not isinstance(cert, dict)
                    or "name" not in cert
                    or "cert" not in cert
                ):
                    raise ValidationError(
                        "Each certificate must have 'name' and 'cert' fields"
                    )
            payload_data["IdP_cert"] = idp_cert

        payload = [payload_data]

        try:
            self.logger.debug(
                "Updating SSO configuration for organization: %s", org_name
            )
            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 updated SSO configuration for: %s", org_name
                )
                return result

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

        except Exception as e:
            if isinstance(e, OrgManagementAPIError):
                raise
            raise OrgManagementAPIError(
                f"Failed to update SSO configuration: {e}"
            ) from e

    @api_tag(MethodType.API_COMMAND, api_command="delete_partner_sso_config")
    def delete_partner_sso_config(
        self,
        org_name: str,
        org_id: str | None = None,
    ) -> bool:
        """Delete a Partner SSO configuration and its associated organization.

        Permanently deletes the Partner SSO configuration for the specified
        organization. **This also deletes the partner user and the organization
        itself, along with all associated data. This action cannot be undone.**

        Args:
            org_name: Name of the organization (case sensitive).
            org_id: Optional organization ID for additional validation.

        Returns:
            True if the configuration was deleted successfully, False otherwise.

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

        Example:
            >>> success = api.delete_partner_sso_config("sso_org")
            >>> if success:
            ...     print("SSO config, partner user, and org permanently deleted")
        """
        if not org_name or not isinstance(org_name, str) or not org_name.strip():
            raise ValidationError("Organization name must be a non-empty string")

        payload_data = {
            "command": "delete_partner_sso_config",
            "org_name": org_name,
        }

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

        payload = [payload_data]

        try:
            self.logger.debug(
                "Deleting SSO configuration for organization: %s", org_name
            )
            response = self._make_api_request("POST", "api/", self.auth_token, payload)

            result = self._extract_api_result(response)
            if isinstance(result, dict):
                if result.get("deleted") == 1 and result.get("status") == 1:
                    self.logger.info(
                        "Successfully deleted SSO configuration for: %s", org_name
                    )
                    return True
            elif result == 1:
                self.logger.info(
                    "Successfully deleted SSO configuration for: %s", org_name
                )
                return True

            self.logger.warning("SSO config deletion may have failed: %s", result)
            return False

        except Exception as e:
            if isinstance(e, OrgManagementAPIError):
                raise
            raise OrgManagementAPIError(
                f"Failed to delete SSO configuration: {e}"
            ) from e

    # -------------------------------------------------------------------------
    # Proxy Policy Management
    # -------------------------------------------------------------------------

    @api_tag(MethodType.CONVENIENCE, wraps=["get_org_children"])
    def get_org_tree(self, org_name: str, max_depth: int = 5) -> dict[str, Any]:
        """Return the full org hierarchy as a nested dict.

        Recursively traverses the organization hierarchy starting at
        ``org_name`` using :meth:`get_org_children` and builds a nested tree
        structure. Each node includes the org name, current user count, and a
        list of child nodes.

        Args:
            org_name: Root organization name to start traversal from.
            max_depth: Maximum number of levels to descend into the org
                hierarchy (default: 5). Set to 1 to return only the root
                node with no children.

        Returns:
            Nested dict representing the org hierarchy::

                {
                    "org_name": "MyOrg",
                    "org_id": "abc123",
                    "current_users": 42,
                    "children": [
                        {
                            "org_name": "MyOrg/Child",
                            "org_id": "def456",
                            "current_users": 10,
                            "children": [...]
                        }
                    ]
                }

            ``org_id`` is ``None`` for a node if its org info could not be
            determined — either the lookup failed, or the response simply
            didn't include an ``org_id``. Unlike ``org_name``, ``org_id`` is
            a stable identifier unaffected by bare-name collisions between
            sibling/cousin orgs elsewhere in the tree.

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

        Example:
            >>> tree = api.get_org_tree("my_company", max_depth=3)
            >>> print(tree["org_name"], tree["current_users"])
            >>> for child in tree["children"]:
            ...     print("  ->", child["org_name"])
        """
        if not org_name or not isinstance(org_name, str) or not org_name.strip():
            raise ValidationError("Organization name must be a non-empty string")
        if max_depth < 1:
            raise ValidationError("max_depth must be at least 1")

        # Deferred import to avoid a module-level dependency of org_api.py
        # (a core API class) on the utils layer; also matches the existing
        # deferred-import convention used elsewhere in the SDK to sidestep
        # import-order/circularity concerns.
        from silo_sdk.utils.org import _walk_org_tree_bfs

        try:
            self.logger.debug(
                "Building org tree for %s (max_depth=%d)", org_name, max_depth
            )

            # _walk_org_tree_bfs is the same shared BFS/cycle-guard engine
            # used by list_users_recursive/get_org_usage_report/
            # get_user_usage_report (see issue #47) — this consumes the
            # (name, path, parent_path) form directly, rather than the
            # flat _collect_org_paths wrapper, because rebuilding this
            # nested tree shape needs the parent link. Previously this
            # traversal had no cycle guard at all, so a malformed/cyclic
            # get_org_children response could produce duplicate nodes.
            nodes: dict[str, dict[str, Any]] = {}
            root_node: dict[str, Any] | None = None

            for name, path, parent_path in _walk_org_tree_bfs(
                self, org_name, max_depth, caller="get_org_tree"
            ):
                node: dict[str, Any] = {
                    "org_name": name,
                    "org_id": None,
                    "current_users": 0,
                    "children": [],
                }

                # Fetch org info for user count (org_id comes along for
                # free). Look up by the full path, not the bare name: a
                # bare name that collides elsewhere in the tree is
                # ambiguous to the platform and the lookup fails. The
                # node's own org_name stays bare — nesting already gives
                # callers the path context.
                try:
                    org_info = self.get_org(path)
                    node["org_id"] = org_info.get("org_id")
                    node["current_users"] = org_info.get(
                        "current_users", org_info.get("user_count", 0)
                    )
                except Exception as e:
                    logger.debug("get_org_tree: skipping node %s: %s", path, e)

                nodes[path] = node
                if parent_path is None:
                    root_node = node
                else:
                    parent_node = nodes.get(parent_path)
                    if parent_node is not None:
                        parent_node["children"].append(node)

            if root_node is None:  # pragma: no cover — root is always yielded first
                raise OrgManagementAPIError("Failed to build org tree: root missing")

            self.logger.info("Built org tree for %s", org_name)
            return root_node
        except Exception as e:  # pragma: no cover
            if isinstance(e, (OrgManagementAPIError, ValidationError)):
                raise
            raise OrgManagementAPIError(f"Failed to build org tree: {e}") from e

    @api_tag(MethodType.API_COMMAND, api_command="policy_proxies.get")
    def get_proxy_policy(
        self,
        org_name: str,
        org_id: str | None = None,
    ) -> list[dict[str, Any]]:
        """Retrieve the proxy objects defined for an organization.

        Returns the list of proxy server entries currently configured for the
        organization. Proxy objects are inherited and aggregated down the org
        hierarchy — child orgs see their own proxies plus those of all parents.

        Args:
            org_name: Organization name to retrieve proxy policy for.
            org_id: Optional organization ID for additional validation.

        Returns:
            List of proxy object dicts. Each dict has a name field and a
            location sub-object with address (required), port (optional),
            and type (optional). Returns an empty list if none configured.

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

        Example:
            >>> proxies = api.get_proxy_policy("my_org")
            >>> for proxy in proxies:
            ...     loc = proxy["location"]
            ...     print(f"{proxy['name']}: {loc['address']}")
        """
        if not org_name or not isinstance(org_name, str) or not org_name.strip():
            raise ValidationError("Organization name must be a non-empty string")

        payload_data: dict[str, Any] = {
            "command": "policy_proxies.get",
            "org_name": org_name,
            "include_proxy_policy": True,
        }
        if org_id is not None:
            payload_data["org_id"] = org_id

        payload = [payload_data]

        try:
            self.logger.debug("Retrieving proxy policy for organization: %s", org_name)
            response = self._make_api_request("POST", "api/", self.auth_token, payload)
            result = self._extract_api_result(response)
            proxies = result if isinstance(result, list) else []
            self.logger.info(
                "Retrieved %d proxy objects for organization: %s",
                len(proxies),
                org_name,
            )
            return proxies
        except Exception as e:
            if isinstance(e, OrgManagementAPIError):
                raise
            raise OrgManagementAPIError(f"Failed to get proxy policy: {e}") from e

    @api_tag(MethodType.API_COMMAND, api_command="policy_proxies.set")
    def set_proxy_policy(
        self,
        org_name: str,
        proxies: list[dict[str, Any]],
        org_id: str | None = None,
    ) -> list[dict[str, Any]]:
        """Replace the proxy policy for an organization.

        Fully overwrites the existing proxy list for the organization with the
        provided list. This operates at the current org level only — it does
        not affect parent or child org proxy configurations.

        Each proxy object requires a unique ``name`` and a ``location.address``
        (IP address or FQDN). Port and type are optional.

        Note:
            Proxy object schema::

                {
                    "name": "eng_proxy_1",       # required, must be unique
                    "location": {
                        "address": "131.131.131.131",  # required, IP or FQDN
                        "port": 8080,                  # optional
                        "type": "http"                 # optional: http (default),
                    }                                  # https, socks, socks4, socks5
                }

            Supported ``type`` values and their default ports:

            - ``http`` (default) — port 80
            - ``https`` — port 443; **address must match SSL cert CN** or users
              see ``ERR_PROXY_CERTIFICATE_INVALID``
            - ``socks`` / ``socks4`` — port 1080; no proxy auth supported
            - ``socks5`` — port 1080; no auth supported in Chrome

        Args:
            org_name: Organization name to update proxy policy for.
            proxies: List of proxy object dicts. An empty list clears all proxies.
            org_id: Optional organization ID for additional validation.

        Returns:
            The updated proxy list as returned by the API.

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

        Example:
            >>> updated = api.set_proxy_policy("my_org", [
            ...     {"name": "corp_proxy", "location": {"address": "10.0.0.1", "port": 8080}},
            ...     {"name": "backup_proxy", "location": {"address": "proxy.corp.com"}},
            ... ])
        """
        if not org_name or not isinstance(org_name, str) or not org_name.strip():
            raise ValidationError("Organization name must be a non-empty string")
        if not isinstance(proxies, list):
            raise ValidationError("proxies must be a list of proxy objects")

        payload_data: dict[str, Any] = {
            "command": "policy_proxies.set",
            "org_name": org_name,
            "proxies": proxies,
        }
        if org_id is not None:
            payload_data["org_id"] = org_id

        payload = [payload_data]

        try:
            self.logger.debug(
                "Setting proxy policy for organization: %s (%d proxies)",
                org_name,
                len(proxies),
            )
            response = self._make_api_request("POST", "api/", self.auth_token, payload)
            result = self._extract_api_result(response)
            self.logger.info(
                "Successfully set proxy policy for organization: %s", org_name
            )
            return result if isinstance(result, list) else []
        except Exception as e:
            if isinstance(e, OrgManagementAPIError):
                raise
            raise OrgManagementAPIError(f"Failed to set proxy policy: {e}") from e

    @api_tag(MethodType.API_COMMAND, api_command="policy_proxies.add")
    def add_proxy_policy(
        self,
        org_name: str,
        proxies: list[dict[str, Any]],
        org_id: str | None = None,
    ) -> str:
        """Append proxy objects to an organization's proxy policy.

        Adds one or more proxy entries to the existing proxy list without
        replacing the existing entries. Proxy names must be unique within
        the org — adding a proxy with a name that already exists will cause
        an error.

        See :meth:`set_proxy_policy` for the proxy object schema and
        supported ``type`` values.

        Args:
            org_name: Organization name to add proxies to.
            proxies: List of proxy object dicts to append. **Multiple proxy
                objects can be added in a single call** by including them all
                in this list.
            org_id: Optional organization ID for additional validation.

        Returns:
            Confirmation message string from the API.

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

        Example:
            >>> # Add multiple proxies in a single call
            >>> msg = api.add_proxy_policy("my_org", [
            ...     {"name": "proxy1", "location": {"address": "10.0.1.1", "port": 3128}},
            ...     {"name": "proxy2", "location": {"address": "backup.corp.com"}},
            ... ])
            >>> print(msg)
        """
        if not org_name or not isinstance(org_name, str) or not org_name.strip():
            raise ValidationError("Organization name must be a non-empty string")
        if not isinstance(proxies, list) or not proxies:
            raise ValidationError("proxies must be a non-empty list of proxy objects")

        payload_data: dict[str, Any] = {
            "command": "policy_proxies.add",
            "org_name": org_name,
            "proxies": proxies,
        }
        if org_id is not None:
            payload_data["org_id"] = org_id

        payload = [payload_data]

        try:
            self.logger.debug(
                "Adding %d proxy object(s) to organization: %s",
                len(proxies),
                org_name,
            )
            response = self._make_api_request("POST", "api/", self.auth_token, payload)
            result = self._extract_api_result(response)
            self.logger.info(
                "Successfully added proxy objects to organization: %s", org_name
            )
            return str(result)
        except Exception as e:
            if isinstance(e, OrgManagementAPIError):
                raise
            raise OrgManagementAPIError(f"Failed to add proxy policy: {e}") from e

    @api_tag(MethodType.API_COMMAND, api_command="policy_proxies.delete")
    def delete_proxy_policy(
        self,
        org_name: str,
        proxy_names: list[str],
        org_id: str | None = None,
    ) -> dict[str, Any]:
        """Remove named proxy objects from an organization's proxy policy.

        Deletes one or more proxy entries by name. Always returns HTTP 200
        with a result dict — no error is raised if a name is not found.
        Check ``proxies_not_found`` in the result to detect missing names.

        Args:
            org_name: Organization name to remove proxies from.
            proxy_names: List of proxy names to delete. Multiple names may be
                provided in a single call.
            org_id: Optional organization ID for additional validation.

        Returns:
            Dictionary with two keys:

            - ``proxies_deleted`` (list): Names successfully removed.
            - ``proxies_not_found`` (list): Names that did not exist (no error
                raised — callers should check this field explicitly).

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

        Example:
            >>> result = api.delete_proxy_policy("my_org", ["proxy1", "proxy2"])
            >>> print(result["proxies_deleted"])    # ["proxy1", "proxy2"]
            >>> print(result["proxies_not_found"])  # [] or names that were missing
        """
        if not org_name or not isinstance(org_name, str) or not org_name.strip():
            raise ValidationError("Organization name must be a non-empty string")
        if not isinstance(proxy_names, list) or not proxy_names:
            raise ValidationError("proxy_names must be a non-empty list of strings")

        payload_data: dict[str, Any] = {
            "command": "policy_proxies.delete",
            "org_name": org_name,
            "proxy_names": proxy_names,
        }
        if org_id is not None:
            payload_data["org_id"] = org_id

        payload = [payload_data]

        try:
            self.logger.debug(
                "Deleting %d proxy object(s) from organization: %s",
                len(proxy_names),
                org_name,
            )
            response = self._make_api_request("POST", "api/", self.auth_token, payload)
            result = self._extract_api_result(response)
            if isinstance(result, dict):
                deleted = result.get("proxies_deleted", [])
                not_found = result.get("proxies_not_found", [])
                self.logger.info(
                    "Proxy delete for %s: deleted=%s, not_found=%s",
                    org_name,
                    deleted,
                    not_found,
                )
                return result
            return {"proxies_deleted": [], "proxies_not_found": proxy_names}
        except Exception as e:
            if isinstance(e, OrgManagementAPIError):
                raise
            raise OrgManagementAPIError(f"Failed to delete proxy policy: {e}") from e

__init__

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

Initialize the organization 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/org_api.py
def __init__(self, config: dict[str, Any]) -> None:
    """Initialize the organization 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, ["ADMIN_TOKEN"])

    self.auth_token = config["ADMIN_TOKEN"]
    self.logger.info("Initialized OrgManagementAPI client")

get_org

get_org(org_name: str | None = None, org_id: str | None = None) -> dict[str, Any]

Get information about an organization.

Retrieves comprehensive information about a specific organization including its configuration, settings, user counts, and other metadata.

At least one of org_name or org_id must be provided. When only org_id is supplied the API resolves the organization by its unique identifier — useful when you have an ID but not the name.

Parameters:

Name Type Description Default
org_name str | None

Silo organization API name to retrieve (e.g., "acme_corp"). This is the organization's API identifier, not the SSO vanity URL. Do not confuse with ORG_VANITY_URL config value (which is for SSO URLs).

None
org_id str | None

Organization ID. Can be used alone for lookup or together with org_name for additional validation.

None
Note

Wire format parameter naming:

  • Org management commands use "org_name" in the wire format
  • User management and log extraction commands use "org" instead
  • org_name is the organization's API identifier, distinct from the ORG_VANITY_URL config setting (which is for SSO login URLs)

Wire format structure::

{
    "command": "org.get",
    "org_name": "acme_corp",  # ← flat, not nested under "data"
    "org_id": "org_abc123"  # ← optional, or used alone for ID lookup
}

Returns:

Type Description
dict[str, Any]

Dictionary containing organization details including:

dict[str, Any]
  • org_name: Organization name
dict[str, Any]
  • org_id: Unique organization identifier
dict[str, Any]
  • created_ts: Organization creation timestamp
dict[str, Any]
  • user_count: Number of users in the organization
dict[str, Any]
  • settings: Organization configuration settings
dict[str, Any]
  • parent_org: Parent organization information (if applicable)

Raises:

Type Description
OrgManagementAPIError

If the API request fails

ValidationError

If neither org_name nor org_id is provided

Example

Look up by name

org_info = api.get_org("acme_corp") print(f"Organization: {org_info['org_name']}")

Look up by ID

org_info = api.get_org(org_id="513f1bdb...") print(f"Organization: {org_info['org_name']}")

Source code in silo_sdk/management/org_api.py
@api_tag(MethodType.API_COMMAND, api_command="org.get")
def get_org(
    self,
    org_name: str | None = None,
    org_id: str | None = None,
) -> dict[str, Any]:
    """Get information about an organization.

    Retrieves comprehensive information about a specific organization including
    its configuration, settings, user counts, and other metadata.

    At least one of ``org_name`` or ``org_id`` must be provided. When only
    ``org_id`` is supplied the API resolves the organization by its unique
    identifier — useful when you have an ID but not the name.

    Args:
        org_name: Silo organization API name to retrieve (e.g., ``"acme_corp"``).
            This is the organization's API identifier, **not** the SSO vanity URL.
            Do not confuse with ``ORG_VANITY_URL`` config value (which is for SSO URLs).
        org_id: Organization ID. Can be used alone for lookup or together
            with ``org_name`` for additional validation.

    Note:
        Wire format parameter naming:

        - Org management commands use ``"org_name"`` in the wire format
        - User management and log extraction commands use ``"org"`` instead
        - ``org_name`` is the organization's API identifier, distinct from the
          ``ORG_VANITY_URL`` config setting (which is for SSO login URLs)

        Wire format structure::

            {
                "command": "org.get",
                "org_name": "acme_corp",  # ← flat, not nested under "data"
                "org_id": "org_abc123"  # ← optional, or used alone for ID lookup
            }

    Returns:
        Dictionary containing organization details including:

        - ``org_name``: Organization name
        - ``org_id``: Unique organization identifier
        - ``created_ts``: Organization creation timestamp
        - ``user_count``: Number of users in the organization
        - ``settings``: Organization configuration settings
        - ``parent_org``: Parent organization information (if applicable)

    Raises:
        OrgManagementAPIError: If the API request fails
        ValidationError: If neither org_name nor org_id is provided

    Example:
        >>> # Look up by name
        >>> org_info = api.get_org("acme_corp")
        >>> print(f"Organization: {org_info['org_name']}")
        >>>
        >>> # Look up by ID
        >>> org_info = api.get_org(org_id="513f1bdb...")
        >>> print(f"Organization: {org_info['org_name']}")
    """
    if not org_name and not org_id:
        raise ValidationError("At least one of org_name or org_id must be provided")

    if org_name is not None and (not org_name or not isinstance(org_name, str)):
        raise ValidationError("org_name must be a non-empty string when provided")

    if org_id is not None and (not org_id or not isinstance(org_id, str)):
        raise ValidationError("org_id must be a non-empty string when provided")

    payload_data: dict[str, Any] = {
        "command": "org.get",
    }

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

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

    payload = [payload_data]

    lookup_key = org_name or org_id
    try:
        self.logger.debug("Getting organization details for: %s", lookup_key)
        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 organization details successfully")
            return result

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

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

resolve_org

resolve_org(org_name: str) -> dict[str, Any]

Resolve an org name to its canonical path and identifiers.

Calls get_org to look up the organization. If the name is ambiguous (the API returns an error listing candidates), those candidates are returned in the candidates field instead of raising an exception.

Parameters:

Name Type Description Default
org_name str

Organization name to resolve. May be a partial name — the API will return candidates if it is ambiguous.

required

Returns:

Type Description
dict[str, Any]

Dictionary with resolved org info::

{ "org_name": "MyOrg/SubOrg", "org_id": "abc123", "parent_org_name": "MyOrg", "candidates": [] }

dict[str, Any]

When the name is ambiguous, candidates contains the list of

dict[str, Any]

matching org names returned by the API and org_name,

dict[str, Any]

org_id, and parent_org_name will be empty strings.

Raises:

Type Description
ValidationError

If org_name is empty.

OrgManagementAPIError

If the API request fails with a non-ambiguity error.

Source code in silo_sdk/management/org_api.py
@api_tag(MethodType.CONVENIENCE, wraps=["get_org"])
def resolve_org(self, org_name: str) -> dict[str, Any]:
    """Resolve an org name to its canonical path and identifiers.

    Calls ``get_org`` to look up the organization. If the name is ambiguous
    (the API returns an error listing candidates), those candidates are
    returned in the ``candidates`` field instead of raising an exception.

    Args:
        org_name: Organization name to resolve. May be a partial name —
            the API will return candidates if it is ambiguous.

    Returns:
        Dictionary with resolved org info::

            {
                "org_name": "MyOrg/SubOrg",
                "org_id": "abc123",
                "parent_org_name": "MyOrg",
                "candidates": []
            }

        When the name is ambiguous, ``candidates`` contains the list of
        matching org names returned by the API and ``org_name``,
        ``org_id``, and ``parent_org_name`` will be empty strings.

    Raises:
        ValidationError: If ``org_name`` is empty.
        OrgManagementAPIError: If the API request fails with a
            non-ambiguity error.
    """
    if not isinstance(org_name, str) or not org_name.strip():
        raise ValidationError("org_name must be a non-empty string")

    payload = [{"command": "org.get", "org_name": org_name}]

    try:
        self.logger.debug("Resolving organization: %s", org_name)
        response = self._make_api_request("POST", "api/", self.auth_token, payload)

        if isinstance(response, list) and len(response) > 1:
            result_entry = response[1]
            if isinstance(result_entry, dict):
                # Check for an error indicating ambiguity
                error_val = result_entry.get("error", "")
                if error_val and isinstance(error_val, str):
                    candidates = result_entry.get("candidates", [])
                    if not candidates and error_val:
                        candidates = [error_val]
                    return {
                        "org_name": "",
                        "org_id": "",
                        "parent_org_name": "",
                        "candidates": candidates,
                    }

                data = result_entry.get("result", {})
                if isinstance(data, dict):
                    return {
                        "org_name": data.get("org_name", org_name),
                        "org_id": data.get("org_id", ""),
                        "parent_org_name": data.get("parent_org_name", ""),
                        "candidates": [],
                    }

        return {
            "org_name": org_name,
            "org_id": "",
            "parent_org_name": "",
            "candidates": [],
        }

    except Exception as e:
        if isinstance(e, (OrgManagementAPIError, ValidationError)):
            raise
        raise OrgManagementAPIError(f"Failed to resolve organization: {e}") from e

get_org_children

get_org_children(org_name: str, org_id: str | None = None) -> list[dict[str, Any]]

Get list of all the sub-organizations of an organization.

Retrieves a list of all child organizations under the specified parent organization, including their basic information and hierarchy details.

Parameters:

Name Type Description Default
org_name str

Silo organization name to get children for

required
org_id str | None

Optional ID of the Silo organization for additional validation

None

Returns:

Type Description
list[dict[str, Any]]

List of dictionaries containing sub-organization details including:

list[dict[str, Any]]
  • org_name: Child organization name
list[dict[str, Any]]
  • org_id: Child organization ID
list[dict[str, Any]]
  • created_ts: Creation timestamp
list[dict[str, Any]]
  • user_count: Number of users in child org
list[dict[str, Any]]
  • depth: Hierarchy depth level

Raises:

Type Description
OrgManagementAPIError

If the API request fails

ValidationError

If parameters are invalid

Example

children = api.get_org_children("parent_org") for child in children: ... print(f"Child org: {child['org_name']} ({child['user_count']} users)")

Source code in silo_sdk/management/org_api.py
@api_tag(MethodType.API_COMMAND, api_command="org.get_children")
def get_org_children(
    self, org_name: str, org_id: str | None = None
) -> list[dict[str, Any]]:
    """Get list of all the sub-organizations of an organization.

    Retrieves a list of all child organizations under the specified parent
    organization, including their basic information and hierarchy details.

    Args:
        org_name: Silo organization name to get children for
        org_id: Optional ID of the Silo organization for additional validation

    Returns:
        List of dictionaries containing sub-organization details including:
        - org_name: Child organization name
        - org_id: Child organization ID
        - created_ts: Creation timestamp
        - user_count: Number of users in child org
        - depth: Hierarchy depth level

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

    Example:
        >>> children = api.get_org_children("parent_org")
        >>> for child in children:
        ...     print(f"Child org: {child['org_name']} ({child['user_count']} users)")
    """
    if not org_name or not isinstance(org_name, str) or not org_name.strip():
        raise ValidationError("Organization name must be a non-empty string")

    payload_data = {
        "command": "org.get_children",
        "org_name": org_name,
    }

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

    payload = [payload_data]

    try:
        self.logger.debug("Getting child organizations for: %s", org_name)
        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 child organizations for %s", len(result), org_name
            )
            return result

        self.logger.warning(
            "Unexpected result format for get_org_children: %s", result
        )
        return []

    except Exception as e:
        if isinstance(e, OrgManagementAPIError):
            raise
        raise OrgManagementAPIError(
            f"Failed to get organization children: {e}"
        ) from e

create_org

create_org(org_name: str, vanity_url: str | None = None, parent_org_name: str | None = None, parent_org_id: str | None = None) -> dict[str, str]

Create a new sub-organization in Silo.

Creates a new organization as a child within the organizational hierarchy. org.create always produces a sub-organization — there is no API mechanism to create a true top-level org. The top of the hierarchy is the "Authentic8 Root of All" organization.

When neither parent_org_name nor parent_org_id is provided, the API places the new org under the organization scope of the authenticating admin token. High-privilege tokens will create directly under "Authentic8 Root of All".

vanity_url is optional for this command. It is required only for :meth:create_partner_sso_config, which creates an org with SSO pre-configured.

Note

SCIM protocol integrations use org_name to map provisioned group names to Silo organizations. In that context the org_name value comes from the identity provider's group name.

Parameters:

Name Type Description Default
org_name str

Name for the new sub-organization.

required
vanity_url str | None

Optional vanity URL for the organization. Required only for SSO-enabled orgs (use :meth:create_partner_sso_config for those).

None
parent_org_name str | None

Name of the parent organization. If omitted (along with parent_org_id), the admin token's org scope is used as the parent.

None
parent_org_id str | None

ID of the parent organization. Alternative to parent_org_name — use one or the other, not both.

None

Returns:

Type Description
dict[str, str]

Dictionary containing the new organization's details including

dict[str, str]

org_id, org_name, vanity_url, and created_ts.

Raises:

Type Description
OrgManagementAPIError

If the API request fails

ValidationError

If parameters are invalid

Example

Create a sub-org under an explicit parent

new_org = api.create_org( ... org_name="engineering_team", ... parent_org_name="my_company", ... ) print(f"Created: {new_org['org_name']} (id: {new_org['org_id']})")

Omit parent — new org is placed under the token's org scope

new_org = api.create_org(org_name="auto_provisioned_group")

Source code in silo_sdk/management/org_api.py
@api_tag(MethodType.API_COMMAND, api_command="org.create")
def create_org(
    self,
    org_name: str,
    vanity_url: str | None = None,
    parent_org_name: str | None = None,
    parent_org_id: str | None = None,
) -> dict[str, str]:
    """Create a new sub-organization in Silo.

    Creates a new organization as a child within the organizational hierarchy.
    ``org.create`` always produces a sub-organization — there is no API mechanism
    to create a true top-level org. The top of the hierarchy is the
    ``"Authentic8 Root of All"`` organization.

    When neither ``parent_org_name`` nor ``parent_org_id`` is provided, the API
    places the new org under the organization scope of the authenticating
    admin token. High-privilege tokens will create directly under
    ``"Authentic8 Root of All"``.

    ``vanity_url`` is optional for this command. It is **required** only for
    :meth:`create_partner_sso_config`, which creates an org with SSO pre-configured.

    Note:
        SCIM protocol integrations use ``org_name`` to map provisioned group names
        to Silo organizations. In that context the ``org_name`` value comes from
        the identity provider's group name.

    Args:
        org_name: Name for the new sub-organization.
        vanity_url: Optional vanity URL for the organization. Required only for
            SSO-enabled orgs (use :meth:`create_partner_sso_config` for those).
        parent_org_name: Name of the parent organization. If omitted (along with
            ``parent_org_id``), the admin token's org scope is used as the parent.
        parent_org_id: ID of the parent organization. Alternative to
            ``parent_org_name`` — use one or the other, not both.

    Returns:
        Dictionary containing the new organization's details including
        ``org_id``, ``org_name``, ``vanity_url``, and ``created_ts``.

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

    Example:
        >>> # Create a sub-org under an explicit parent
        >>> new_org = api.create_org(
        ...     org_name="engineering_team",
        ...     parent_org_name="my_company",
        ... )
        >>> print(f"Created: {new_org['org_name']} (id: {new_org['org_id']})")

        >>> # Omit parent — new org is placed under the token's org scope
        >>> new_org = api.create_org(org_name="auto_provisioned_group")
    """
    # Validate required parameters
    if not org_name or not isinstance(org_name, str) or not org_name.strip():
        raise ValidationError("Organization name must be a non-empty string")

    # Build payload
    payload_data: dict[str, Any] = {
        "command": "org.create",
        "org_name": org_name,
    }

    # Add optional parameters
    if vanity_url is not None:
        if not isinstance(vanity_url, str):
            raise ValidationError("Vanity URL must be a string")
        payload_data["vanity_url"] = vanity_url

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

    if parent_org_id is not None:
        if not isinstance(parent_org_id, str):
            raise ValidationError("Parent organization ID must be a string")
        payload_data["parent_org_id"] = parent_org_id

    payload = [payload_data]

    try:
        self.logger.debug("Creating organization: %s", org_name)
        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 created organization: %s", org_name)
            return result

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

    except Exception as e:
        if isinstance(e, OrgManagementAPIError):
            raise
        raise OrgManagementAPIError(f"Failed to create organization: {e}") from e

get_session_report

get_session_report(org_name: str | None = None, start_date: str | None = None, end_date: str | None = None, username: str | None = None, org_id: str | None = None, user_id: str | None = None, hierarchy: bool = False) -> dict[str, Any]

Get a high level report of sessions and isolation consumption.

Generates a report of session statistics for the specified organization and time period.

The API uses a priority waterfall for identifiers — only the first match is used: user_id > username > org_id > org_name

hierarchy. Provide only one per call. The hierarchy flag is mutually exclusive with other identifiers — the API silently ignores it when a higher-priority identifier is present.

Parameters:

Name Type Description Default
org_name str | None

Organization name to report on.

None
start_date str | None

Start date in MM-DD-YYYY format.

None
end_date str | None

End date in MM-DD-YYYY format.

None
username str | None

Username (email) to filter the report for a specific user.

None
org_id str | None

Organization ID to report on (alternative to org_name).

None
user_id str | None

User ID to filter the report for a specific user (alternative to username).

None
hierarchy bool

When True, include all child orgs in the report. Cannot be combined with other identifiers — the API only honours this flag when no other identifier is provided.

False

Returns:

Type Description
dict[str, Any]

Dictionary containing session report data returned by the server.

Raises:

Type Description
OrgManagementAPIError

If the API request fails

ValidationError

If date format is invalid (including a syntactically plausible but wrong-format date, e.g. ISO YYYY-MM-DD instead of MM-DD-YYYY) or multiple identifiers are provided

Example

report = api.get_session_report( ... org_name="my_company", ... start_date="01-01-2024", ... end_date="01-31-2024", ... ) report = api.get_session_report( ... org_id="7c5db979...", ... start_date="01-01-2024", ... end_date="01-31-2024", ... )

Source code in silo_sdk/management/org_api.py
@api_tag(MethodType.API_COMMAND, api_command="session_report")
def get_session_report(
    self,
    org_name: str | None = None,
    start_date: str | None = None,
    end_date: str | None = None,
    username: str | None = None,
    org_id: str | None = None,
    user_id: str | None = None,
    hierarchy: bool = False,
) -> dict[str, Any]:
    """Get a high level report of sessions and isolation consumption.

    Generates a report of session statistics for the specified organization
    and time period.

    The API uses a priority waterfall for identifiers — only the first
    match is used: ``user_id`` > ``username`` > ``org_id`` > ``org_name``
    > ``hierarchy``. Provide only one per call. The ``hierarchy`` flag
    is **mutually exclusive** with other identifiers — the API silently
    ignores it when a higher-priority identifier is present.

    Args:
        org_name: Organization name to report on.
        start_date: Start date in ``MM-DD-YYYY`` format.
        end_date: End date in ``MM-DD-YYYY`` format.
        username: Username (email) to filter the report for a specific user.
        org_id: Organization ID to report on (alternative to ``org_name``).
        user_id: User ID to filter the report for a specific user
            (alternative to ``username``).
        hierarchy: When True, include all child orgs in the report.
            Cannot be combined with other identifiers — the API only
            honours this flag when no other identifier is provided.

    Returns:
        Dictionary containing session report data returned by the server.

    Raises:
        OrgManagementAPIError: If the API request fails
        ValidationError: If date format is invalid (including a
            syntactically plausible but wrong-format date, e.g. ISO
            ``YYYY-MM-DD`` instead of ``MM-DD-YYYY``) or multiple
            identifiers are provided

    Example:
        >>> report = api.get_session_report(
        ...     org_name="my_company",
        ...     start_date="01-01-2024",
        ...     end_date="01-31-2024",
        ... )
        >>> report = api.get_session_report(
        ...     org_id="7c5db979...",
        ...     start_date="01-01-2024",
        ...     end_date="01-31-2024",
        ... )
    """
    # Validate that at most one identifier is provided.
    # The API uses a priority waterfall — only the first match from
    # [user_id, username, org_id, org_name, :hierarchy] is sent to the
    # backend.  hierarchy is silently ignored when combined with another
    # identifier, so we reject the combination here to avoid confusion.
    identifiers = [
        ("user_id", user_id),
        ("username", username),
        ("org_id", org_id),
        ("org_name", org_name),
    ]
    provided = [name for name, val in identifiers if val is not None]
    if hierarchy and provided:
        raise ValidationError(
            "hierarchy cannot be combined with other identifiers "
            f"({', '.join(provided)}) — the API ignores hierarchy when "
            "a higher-priority identifier is present"
        )
    if len(provided) > 1:
        raise ValidationError(
            f"Only one identifier may be provided, got: {', '.join(provided)}"
        )

    # Validate date formats if provided.
    #
    # Strict parsing (rather than the old loose length/dash-count
    # check) is required so that a syntactically similar but wrong
    # format — most notably ISO 8601 (YYYY-MM-DD) — is rejected instead
    # of silently passing through to the wire, where it produces
    # wrong/undefined server-side behavior (see issue #44).
    if start_date is not None:
        if not isinstance(start_date, str):
            raise ValidationError(
                "Start date must be a string in MM-DD-YYYY format"
            )
        if not _is_valid_mm_dd_yyyy(start_date):
            raise ValidationError(
                f"Start date must be in MM-DD-YYYY format, got: {start_date!r}"
            )

    if end_date is not None:
        if not isinstance(end_date, str):
            raise ValidationError("End date must be a string in MM-DD-YYYY format")
        if not _is_valid_mm_dd_yyyy(end_date):
            raise ValidationError(
                f"End date must be in MM-DD-YYYY format, got: {end_date!r}"
            )

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

    # Add the identifier (only one will be set)
    if user_id is not None:
        if not isinstance(user_id, str):
            raise ValidationError("user_id must be a string")
        payload_data["user_id"] = user_id

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

    if org_id is not None:
        if not isinstance(org_id, str):
            raise ValidationError("org_id must be a non-empty string")
        payload_data["org_id"] = org_id

    if org_name is not None:
        if not isinstance(org_name, str) or not org_name.strip():
            raise ValidationError("org_name must be a non-empty string")
        payload_data["org_name"] = org_name

    if hierarchy:
        payload_data[":hierarchy"] = True

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

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

    payload = [payload_data]

    try:
        self.logger.debug(
            "Generating session report for organization: %s", org_name or "default"
        )
        response = self._make_api_request("POST", "api/", self.auth_token, payload)

        result = self._extract_api_result(response)
        if isinstance(result, dict):
            if "error" in result:
                raise OrgManagementAPIError(f"API error: {result['error']}")
            self.logger.info("Successfully generated session report")
            return result

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

    except Exception as e:
        if isinstance(e, OrgManagementAPIError):
            raise
        raise OrgManagementAPIError(f"Failed to get session report: {e}") from e

update_org

update_org(org_name: str, new_org_name: str | None = None, vanity_url: str | None = None, parent_org_name: str | None = None, parent_org_id: str | None = None, org_id: str | None = None) -> dict[str, Any]

Update an existing Silo organization.

Updates various properties of an existing organization including its name, vanity URL, and parent organization relationships.

Parameters:

Name Type Description Default
org_name str

Current name of the organization to update

required
new_org_name str | None

New name for the organization

None
vanity_url str | None

New vanity URL for the organization

None
parent_org_name str | None

Name of the new parent organization

None
parent_org_id str | None

ID of the new parent organization

None
org_id str | None

Optional organization ID for additional validation

None

Returns:

Type Description
dict[str, Any]

Dictionary containing the updated organization details

Raises:

Type Description
OrgManagementAPIError

If the API request fails

ValidationError

If parameters are invalid

Example

updated_org = api.update_org( ... org_name="old_name", ... new_org_name="new_name", ... vanity_url="new-vanity", ... parent_org_name="new_parent" ... )

Source code in silo_sdk/management/org_api.py
@api_tag(MethodType.API_COMMAND, api_command="org.update")
def update_org(
    self,
    org_name: str,
    new_org_name: str | None = None,
    vanity_url: str | None = None,
    parent_org_name: str | None = None,
    parent_org_id: str | None = None,
    org_id: str | None = None,
) -> dict[str, Any]:
    """Update an existing Silo organization.

    Updates various properties of an existing organization including its name,
    vanity URL, and parent organization relationships.

    Args:
        org_name: Current name of the organization to update
        new_org_name: New name for the organization
        vanity_url: New vanity URL for the organization
        parent_org_name: Name of the new parent organization
        parent_org_id: ID of the new parent organization
        org_id: Optional organization ID for additional validation

    Returns:
        Dictionary containing the updated organization details

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

    Example:
        >>> updated_org = api.update_org(
        ...     org_name="old_name",
        ...     new_org_name="new_name",
        ...     vanity_url="new-vanity",
        ...     parent_org_name="new_parent"
        ... )
    """
    if not org_name or not isinstance(org_name, str) or not org_name.strip():
        raise ValidationError("Organization name must be a non-empty string")

    payload_data = {
        "command": "org.update",
        "org_name": org_name,
    }

    # Add optional parameters
    if new_org_name is not None:
        if not isinstance(new_org_name, str):
            raise ValidationError("New organization name must be a string")
        payload_data["new_org_name"] = new_org_name

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

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

    if parent_org_id is not None:
        if not isinstance(parent_org_id, str):
            raise ValidationError("Parent organization ID must be a string")
        payload_data["parent_org_id"] = parent_org_id

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

    payload = [payload_data]

    try:
        self.logger.debug("Updating organization: %s", org_name)
        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 updated organization: %s", org_name)
            return result
        if isinstance(result, list) and len(result) > 0:
            org_details = result[0]
            if isinstance(org_details, dict):
                self.logger.info("Successfully updated organization: %s", org_name)
                return org_details

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

    except Exception as e:
        if isinstance(e, OrgManagementAPIError):
            raise
        raise OrgManagementAPIError(f"Failed to update organization: {e}") from e

delete_org

delete_org(org_name: str) -> bool

Delete a Silo organization.

The server performs a soft delete: it stamps the organization with an expiration timestamp and queues a background task to remove it. The organization is immediately unusable and there is no API to undo this.

Deletion is refused — as an error, not a False return — when the organization still contains users, when it still has sub-orgs, or when the calling admin's own permissions are anchored to it. Delete users and child orgs first, working leaf-first up the tree.

Parameters:

Name Type Description Default
org_name str

Name of the organization to delete

required

Returns:

Type Description
bool

True. This method never returns False: deletion is either confirmed

bool

by the server or an exception is raised. Treat

bool

OrgManagementAPIError as the failure signal — do not branch on

bool

the return value.

Raises:

Type Description
OrgManagementAPIError

If the server refuses the deletion, the API request fails, or the response does not confirm the deletion

ValidationError

If parameters are invalid

Example

try: ... api.delete_org("org_to_delete") ... print("Organization deleted successfully") ... except OrgManagementAPIError as exc: ... print(f"Deletion failed: {exc}")

Source code in silo_sdk/management/org_api.py
@api_tag(MethodType.API_COMMAND, api_command="org.delete")
def delete_org(
    self,
    org_name: str,
) -> bool:
    """Delete a Silo organization.

    The server performs a soft delete: it stamps the organization with an
    expiration timestamp and queues a background task to remove it. The
    organization is immediately unusable and there is no API to undo this.

    Deletion is refused — as an error, not a ``False`` return — when the
    organization still contains users, when it still has sub-orgs, or when
    the calling admin's own permissions are anchored to it. Delete users
    and child orgs first, working leaf-first up the tree.

    Args:
        org_name: Name of the organization to delete

    Returns:
        True. This method never returns False: deletion is either confirmed
        by the server or an exception is raised. Treat
        ``OrgManagementAPIError`` as the failure signal — do not branch on
        the return value.

    Raises:
        OrgManagementAPIError: If the server refuses the deletion, the API
            request fails, or the response does not confirm the deletion
        ValidationError: If parameters are invalid

    Example:
        >>> try:
        ...     api.delete_org("org_to_delete")
        ...     print("Organization deleted successfully")
        ... except OrgManagementAPIError as exc:
        ...     print(f"Deletion failed: {exc}")
    """
    if not org_name or not isinstance(org_name, str) or not org_name.strip():
        raise ValidationError("Organization name must be a non-empty string")

    payload = [{"command": "org.delete", "org_name": org_name}]

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

        result = self._extract_api_result(response)
        if isinstance(result, dict):
            if result.get("deleted") == 1 and result.get("status") == 1:
                self.logger.info("Successfully deleted organization: %s", org_name)
                return True

        # Refusals and failures arrive as an error entry, which
        # _extract_api_result has already raised on. Anything else means the
        # server confirmed nothing, so surface it rather than reporting a
        # deletion that may or may not have happened.
        raise OrgManagementAPIError(
            f"Organization deletion was not confirmed by the server "
            f"(org_name={org_name!r}, result={result!r})"
        )

    except Exception as e:
        if isinstance(e, OrgManagementAPIError):
            raise
        raise OrgManagementAPIError(f"Failed to delete organization: {e}") from e

create_partner_sso_config

create_partner_sso_config(org_name: str, vanity_url: str, idp_name: str, idp_login_url: str, idp_cert: list[dict[str, str]], parent_org_name: str | None = None, parent_org_id: str | None = None) -> dict[str, Any]

Create a new organization with SSO settings.

Creates a new organization and configures its Single Sign-On policy with the provided information. The organization will automatically have SSO enabled after creation.

Parameters:

Name Type Description Default
org_name str

Name for the new organization (creates a new org, not configures SSO on an existing one).

required
vanity_url str

Vanity URL for the organization (must be unique).

required
idp_name str

Identity Provider display name. Sent as "IdP_name" in the wire format (capitalization differs from this param name).

required
idp_login_url str

SSO login URL. Sent as "IdP_login_URL" in the wire format (capitalization differs from this param name).

required
idp_cert list[dict[str, str]]

List of certificate dicts with "name", "cert", and optional "notes" keys. Sent as "IdP_cert" in the wire format.

required
parent_org_name str | None

Name of the parent organization this org will be created under. Required in practice — omitting it creates the org at the root level, which is typically not permitted. Either parent_org_name or parent_org_id must be provided.

None
parent_org_id str | None

ID of the parent organization (alternative to parent_org_name). Use one or the other, not both.

None
Note

Wire format parameter name differences (all case-sensitive):

  • Python idp_name → wire "IdP_name"
  • Python idp_login_url → wire "IdP_login_URL"
  • Python idp_cert → wire "IdP_cert"

This creates a new organization with SSO pre-configured. It does not add SSO to an existing org.

Returns:

Type Description
dict[str, Any]

Dictionary containing the new organization's SSO configuration, including:

dict[str, Any]
  • org_id: Unique organization identifier
dict[str, Any]
  • org_name: Organization name
dict[str, Any]
  • IdP_name: Identity Provider name
dict[str, Any]
  • IdP_login_URL: IdP SSO login URL
dict[str, Any]
  • IdP_cert: List of IdP certificates (each with name, cert, optional notes, and auto-generated upload_ts)
dict[str, Any]
  • SP_cert: Silo Service Provider certificate
dict[str, Any]
  • SP_entity_id: SAML SP entity ID URL
dict[str, Any]
  • a8_portal_url: Silo Access Portal URL for the org
dict[str, Any]
  • a8_postback_url: SAML ACS (assertion consumer service) postback URL
dict[str, Any]
  • partner_SSO_sign_cert: Partner SSO signing certificate

Raises:

Type Description
OrgManagementAPIError

If the API request fails

ValidationError

If parameters are invalid

Example

sso_org = api.create_partner_sso_config( ... org_name="sso_org", ... vanity_url="sso-org", ... idp_name="MyIdP", ... idp_login_url="https://idp.example.com/sso", ... idp_cert=[{ ... "name": "idp_cert", ... "cert": "-----BEGIN CERTIFICATE-----...", ... "notes": "Main IdP certificate" ... }], ... parent_org_name="parent_org" ... )

Source code in silo_sdk/management/org_api.py
@api_tag(MethodType.API_COMMAND, api_command="create_partner_sso_config")
def create_partner_sso_config(
    self,
    org_name: str,
    vanity_url: str,
    idp_name: str,
    idp_login_url: str,
    idp_cert: list[dict[str, str]],
    parent_org_name: str | None = None,
    parent_org_id: str | None = None,
) -> dict[str, Any]:
    """Create a new organization with SSO settings.

    Creates a new organization and configures its Single Sign-On policy
    with the provided information. The organization will automatically
    have SSO enabled after creation.

    Args:
        org_name: Name for the new organization (creates a new org, not
            configures SSO on an existing one).
        vanity_url: Vanity URL for the organization (must be unique).
        idp_name: Identity Provider display name. Sent as ``"IdP_name"``
            in the wire format (capitalization differs from this param name).
        idp_login_url: SSO login URL. Sent as ``"IdP_login_URL"`` in the
            wire format (capitalization differs from this param name).
        idp_cert: List of certificate dicts with ``"name"``, ``"cert"``,
            and optional ``"notes"`` keys. Sent as ``"IdP_cert"`` in the
            wire format.
        parent_org_name: Name of the parent organization this org will be
            created under. Required in practice — omitting it creates the
            org at the root level, which is typically not permitted.
            Either ``parent_org_name`` or ``parent_org_id`` must be provided.
        parent_org_id: ID of the parent organization (alternative to
            ``parent_org_name``). Use one or the other, not both.

    Note:
        Wire format parameter name differences (all case-sensitive):

        - Python ``idp_name`` → wire ``"IdP_name"``
        - Python ``idp_login_url`` → wire ``"IdP_login_URL"``
        - Python ``idp_cert`` → wire ``"IdP_cert"``

        This creates a **new organization** with SSO pre-configured.
        It does not add SSO to an existing org.

    Returns:
        Dictionary containing the new organization's SSO configuration, including:

        - ``org_id``: Unique organization identifier
        - ``org_name``: Organization name
        - ``IdP_name``: Identity Provider name
        - ``IdP_login_URL``: IdP SSO login URL
        - ``IdP_cert``: List of IdP certificates (each with ``name``, ``cert``,
            optional ``notes``, and auto-generated ``upload_ts``)
        - ``SP_cert``: Silo Service Provider certificate
        - ``SP_entity_id``: SAML SP entity ID URL
        - ``a8_portal_url``: Silo Access Portal URL for the org
        - ``a8_postback_url``: SAML ACS (assertion consumer service) postback URL
        - ``partner_SSO_sign_cert``: Partner SSO signing certificate

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

    Example:
        >>> sso_org = api.create_partner_sso_config(
        ...     org_name="sso_org",
        ...     vanity_url="sso-org",
        ...     idp_name="MyIdP",
        ...     idp_login_url="https://idp.example.com/sso",
        ...     idp_cert=[{
        ...         "name": "idp_cert",
        ...         "cert": "-----BEGIN CERTIFICATE-----...",
        ...         "notes": "Main IdP certificate"
        ...     }],
        ...     parent_org_name="parent_org"
        ... )
    """
    # Validate required parameters
    if not org_name or not isinstance(org_name, str) or not org_name.strip():
        raise ValidationError("Organization name must be a non-empty string")

    if not vanity_url or not isinstance(vanity_url, str):
        raise ValidationError("Vanity URL must be a non-empty string")

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

    if not idp_login_url or not isinstance(idp_login_url, str):
        raise ValidationError("IdP login URL must be a non-empty string")

    if not isinstance(idp_cert, list) or not idp_cert:
        raise ValidationError("IdP certificates must be a non-empty list")

    # Validate certificate format
    for cert in idp_cert:
        if not isinstance(cert, dict) or "name" not in cert or "cert" not in cert:
            raise ValidationError(
                "Each certificate must have 'name' and 'cert' fields"
            )

    payload_data = {
        "command": "create_partner_sso_config",
        "org_name": org_name,
        "vanity_url": vanity_url,
        "IdP_name": idp_name,
        "IdP_login_URL": idp_login_url,
        "IdP_cert": idp_cert,
    }

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

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

    payload = [payload_data]

    try:
        self.logger.debug("Creating organization with SSO: %s", org_name)
        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 created organization with SSO: %s", org_name
            )
            return result

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

    except Exception as e:
        if isinstance(e, OrgManagementAPIError):
            raise
        raise OrgManagementAPIError(
            f"Failed to create organization with SSO: {e}"
        ) from e

get_partner_sso_config

get_partner_sso_config(org_name: str, org_id: str | None = None) -> dict[str, Any]

Get SSO configuration for an organization.

Retrieves the Single Sign-On configuration settings for the specified organization including IdP details and certificates.

Parameters:

Name Type Description Default
org_name str

Name of the organization

required
org_id str | None

Optional organization ID for additional validation

None
Note

If SSO is disabled for the organization, the API returns an error response rather than an empty result. Callers checking whether SSO exists should catch OrgManagementAPIError and inspect the message for "SSO policy is disabled" or "not found".

Returns:

Type Description
dict[str, Any]

Dictionary containing SSO configuration details, including:

dict[str, Any]
  • org_id: Unique organization identifier
dict[str, Any]
  • org_name: Organization name
dict[str, Any]
  • IdP_name: Identity Provider name
dict[str, Any]
  • IdP_login_URL: IdP SSO login URL
dict[str, Any]
  • IdP_cert: List of IdP certificates (each with name, cert, optional notes, and auto-generated upload_ts)
dict[str, Any]
  • SP_cert: Silo Service Provider certificate
dict[str, Any]
  • SP_entity_id: SAML SP entity ID URL
dict[str, Any]
  • a8_portal_url: Silo Access Portal URL for the org
dict[str, Any]
  • a8_postback_url: SAML ACS (assertion consumer service) postback URL
dict[str, Any]
  • partner_SSO_sign_cert: Partner SSO signing certificate

Raises:

Type Description
OrgManagementAPIError

If the API request fails or SSO is disabled

ValidationError

If parameters are invalid

Example

sso_config = api.get_partner_sso_config("sso_org") print(f"IdP: {sso_config['IdP_name']}") print(f"Portal: {sso_config['a8_portal_url']}")

Source code in silo_sdk/management/org_api.py
@api_tag(MethodType.API_COMMAND, api_command="get_partner_sso_config")
def get_partner_sso_config(
    self,
    org_name: str,
    org_id: str | None = None,
) -> dict[str, Any]:
    """Get SSO configuration for an organization.

    Retrieves the Single Sign-On configuration settings for the specified
    organization including IdP details and certificates.

    Args:
        org_name: Name of the organization
        org_id: Optional organization ID for additional validation

    Note:
        If SSO is disabled for the organization, the API returns an error
        response rather than an empty result. Callers checking whether SSO
        exists should catch ``OrgManagementAPIError`` and inspect the message
        for ``"SSO policy is disabled"`` or ``"not found"``.

    Returns:
        Dictionary containing SSO configuration details, including:

        - ``org_id``: Unique organization identifier
        - ``org_name``: Organization name
        - ``IdP_name``: Identity Provider name
        - ``IdP_login_URL``: IdP SSO login URL
        - ``IdP_cert``: List of IdP certificates (each with ``name``, ``cert``,
            optional ``notes``, and auto-generated ``upload_ts``)
        - ``SP_cert``: Silo Service Provider certificate
        - ``SP_entity_id``: SAML SP entity ID URL
        - ``a8_portal_url``: Silo Access Portal URL for the org
        - ``a8_postback_url``: SAML ACS (assertion consumer service) postback URL
        - ``partner_SSO_sign_cert``: Partner SSO signing certificate

    Raises:
        OrgManagementAPIError: If the API request fails or SSO is disabled
        ValidationError: If parameters are invalid

    Example:
        >>> sso_config = api.get_partner_sso_config("sso_org")
        >>> print(f"IdP: {sso_config['IdP_name']}")
        >>> print(f"Portal: {sso_config['a8_portal_url']}")
    """
    if not org_name or not isinstance(org_name, str) or not org_name.strip():
        raise ValidationError("Organization name must be a non-empty string")

    payload_data = {
        "command": "get_partner_sso_config",
        "org_name": org_name,
    }

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

    payload = [payload_data]

    try:
        self.logger.debug("Getting SSO config for organization: %s", org_name)
        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 SSO configuration successfully")
            return result

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

    except Exception as e:
        if isinstance(e, OrgManagementAPIError):
            raise
        raise OrgManagementAPIError(f"Failed to get SSO configuration: {e}") from e

enable_partner_sso_config

enable_partner_sso_config(org_name: str, org_id: str | None = None) -> bool

Enable SSO for an organization.

Enables the Single Sign-On policy for the specified organization.

Parameters:

Name Type Description Default
org_name str

Name of the organization

required
org_id str | None

Optional organization ID for additional validation

None

Returns:

Type Description
bool

True if SSO was enabled successfully, False otherwise

Raises:

Type Description
OrgManagementAPIError

If the API request fails

ValidationError

If parameters are invalid

Source code in silo_sdk/management/org_api.py
@api_tag(MethodType.API_COMMAND, api_command="enable_partner_sso_config")
def enable_partner_sso_config(
    self,
    org_name: str,
    org_id: str | None = None,
) -> bool:
    """Enable SSO for an organization.

    Enables the Single Sign-On policy for the specified organization.

    Args:
        org_name: Name of the organization
        org_id: Optional organization ID for additional validation

    Returns:
        True if SSO was enabled successfully, False otherwise

    Raises:
        OrgManagementAPIError: If the API request fails
        ValidationError: If parameters are invalid
    """
    if not org_name or not isinstance(org_name, str) or not org_name.strip():
        raise ValidationError("Organization name must be a non-empty string")

    payload_data = {
        "command": "enable_partner_sso_config",
        "org_name": org_name,
    }

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

    payload = [payload_data]

    try:
        self.logger.debug("Enabling SSO for organization: %s", org_name)
        response = self._make_api_request("POST", "api/", self.auth_token, payload)

        result = self._extract_api_result(response)
        if result == 1:
            self.logger.info(
                "Successfully enabled SSO for organization: %s", org_name
            )
            return True

        self.logger.warning("SSO enable may have failed: %s", result)
        return False

    except Exception as e:
        if isinstance(e, OrgManagementAPIError):
            raise
        raise OrgManagementAPIError(f"Failed to enable SSO: {e}") from e

disable_partner_sso_config

disable_partner_sso_config(org_name: str, org_id: str | None = None) -> bool

Disable SSO for an organization.

Disables the Single Sign-On policy for the specified organization. The partner user will be suspended and will no longer be able to access the Silo platform.

Parameters:

Name Type Description Default
org_name str

Name of the organization

required
org_id str | None

Optional organization ID for additional validation

None

Returns:

Type Description
bool

True if SSO was disabled successfully, False otherwise

Raises:

Type Description
OrgManagementAPIError

If the API request fails

ValidationError

If parameters are invalid

Source code in silo_sdk/management/org_api.py
@api_tag(MethodType.API_COMMAND, api_command="disable_partner_sso_config")
def disable_partner_sso_config(
    self,
    org_name: str,
    org_id: str | None = None,
) -> bool:
    """Disable SSO for an organization.

    Disables the Single Sign-On policy for the specified organization.
    The partner user will be suspended and will no longer be able to access
    the Silo platform.

    Args:
        org_name: Name of the organization
        org_id: Optional organization ID for additional validation

    Returns:
        True if SSO was disabled successfully, False otherwise

    Raises:
        OrgManagementAPIError: If the API request fails
        ValidationError: If parameters are invalid
    """
    if not org_name or not isinstance(org_name, str) or not org_name.strip():
        raise ValidationError("Organization name must be a non-empty string")

    payload_data = {
        "command": "disable_partner_sso_config",
        "org_name": org_name,
    }

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

    payload = [payload_data]

    try:
        self.logger.debug("Disabling SSO for organization: %s", org_name)
        response = self._make_api_request("POST", "api/", self.auth_token, payload)

        result = self._extract_api_result(response)
        if result == 1:
            self.logger.info(
                "Successfully disabled SSO for organization: %s", org_name
            )
            return True

        self.logger.warning("SSO disable may have failed: %s", result)
        return False

    except Exception as e:
        if isinstance(e, OrgManagementAPIError):
            raise
        raise OrgManagementAPIError(f"Failed to disable SSO: {e}") from e

update_partner_sso_config

update_partner_sso_config(org_name: str, org_id: str | None = None, new_org_name: str | None = None, parent_org_name: str | None = None, parent_org_id: str | None = None, vanity_url: str | None = None, idp_name: str | None = None, idp_login_url: str | None = None, idp_cert: list[dict[str, str]] | None = None) -> dict[str, Any]

Update an existing SSO configuration, or add SSO to an existing org.

Updates the Partner SSO configuration for an organization. All IdP fields are optional — supply only the fields you want to change.

This method also works to add SSO to an existing organization that has no SSO configuration yet. In that case, pass all required IdP fields (idp_name, idp_login_url, idp_cert) along with vanity_url.

Parameters:

Name Type Description Default
org_name str

Current name of the organization (case sensitive).

required
org_id str | None

Optional organization ID for additional validation.

None
new_org_name str | None

New name for the organization. Must be unique under the parent org.

None
parent_org_name str | None

New parent organization name (moves the org).

None
parent_org_id str | None

New parent organization ID (moves the org).

None
vanity_url str | None

New vanity URL. Must be unique across the system.

None
idp_name str | None

Identity Provider display name. Sent as "IdP_name" in the wire format.

None
idp_login_url str | None

SSO login URL (also known as Single Sign-on URL). Sent as "IdP_login_URL" in the wire format.

None
idp_cert list[dict[str, str]] | None

Full list of IdP certificates. To add a cert, append it to the array returned by get_partner_sso_config. To remove a cert, omit it from the array. To update notes, modify the entry in place. The upload_ts field is auto-generated and cannot be updated. Sent as "IdP_cert" in the wire format.

None
Note

Wire format parameter name differences (all case-sensitive):

  • Python idp_name → wire "IdP_name"
  • Python idp_login_url → wire "IdP_login_URL"
  • Python idp_cert → wire "IdP_cert"

Certificate array management: the API replaces the entire cert list with what you send. Always fetch the current list from get_partner_sso_config first, modify it, then pass the full modified list here.

Returns:

Type Description
dict[str, Any]

Dictionary containing the updated SSO configuration, including:

dict[str, Any]
  • org_id: Unique organization identifier
dict[str, Any]
  • org_name: Organization name
dict[str, Any]
  • IdP_name: Identity Provider name
dict[str, Any]
  • IdP_login_URL: IdP SSO login URL
dict[str, Any]
  • IdP_cert: Updated list of IdP certificates
dict[str, Any]
  • SP_cert: Silo Service Provider certificate
dict[str, Any]
  • SP_entity_id: SAML SP entity ID URL
dict[str, Any]
  • a8_portal_url: Silo Access Portal URL for the org
dict[str, Any]
  • a8_postback_url: SAML ACS postback URL
dict[str, Any]
  • partner_SSO_sign_cert: Partner SSO signing certificate

Raises:

Type Description
OrgManagementAPIError

If the API request fails

ValidationError

If parameters are invalid

Example

Update IdP login URL only

updated = api.update_partner_sso_config( ... org_name="sso_org", ... idp_login_url="https://new-idp.example.com/sso", ... )

Add a new certificate without removing existing ones

current = api.get_partner_sso_config("sso_org") new_certs = current["IdP_cert"] + [{ ... "name": "new_cert", ... "cert": "-----BEGIN CERTIFICATE-----...", ... }] api.update_partner_sso_config("sso_org", idp_cert=new_certs)

Source code in silo_sdk/management/org_api.py
@api_tag(MethodType.API_COMMAND, api_command="update_partner_sso_config")
def update_partner_sso_config(
    self,
    org_name: str,
    org_id: str | None = None,
    new_org_name: str | None = None,
    parent_org_name: str | None = None,
    parent_org_id: str | None = None,
    vanity_url: str | None = None,
    idp_name: str | None = None,
    idp_login_url: str | None = None,
    idp_cert: list[dict[str, str]] | None = None,
) -> dict[str, Any]:
    """Update an existing SSO configuration, or add SSO to an existing org.

    Updates the Partner SSO configuration for an organization. All IdP fields
    are optional — supply only the fields you want to change.

    This method also works to **add** SSO to an existing organization that has
    no SSO configuration yet. In that case, pass all required IdP fields
    (``idp_name``, ``idp_login_url``, ``idp_cert``) along with ``vanity_url``.

    Args:
        org_name: Current name of the organization (case sensitive).
        org_id: Optional organization ID for additional validation.
        new_org_name: New name for the organization. Must be unique under
            the parent org.
        parent_org_name: New parent organization name (moves the org).
        parent_org_id: New parent organization ID (moves the org).
        vanity_url: New vanity URL. Must be unique across the system.
        idp_name: Identity Provider display name. Sent as ``"IdP_name"``
            in the wire format.
        idp_login_url: SSO login URL (also known as Single Sign-on URL).
            Sent as ``"IdP_login_URL"`` in the wire format.
        idp_cert: Full list of IdP certificates. To add a cert, append it
            to the array returned by ``get_partner_sso_config``. To remove
            a cert, omit it from the array. To update ``notes``, modify the
            entry in place. The ``upload_ts`` field is auto-generated and
            cannot be updated. Sent as ``"IdP_cert"`` in the wire format.

    Note:
        Wire format parameter name differences (all case-sensitive):

        - Python ``idp_name`` → wire ``"IdP_name"``
        - Python ``idp_login_url`` → wire ``"IdP_login_URL"``
        - Python ``idp_cert`` → wire ``"IdP_cert"``

        Certificate array management: the API replaces the entire cert list
        with what you send. Always fetch the current list from
        ``get_partner_sso_config`` first, modify it, then pass the full
        modified list here.

    Returns:
        Dictionary containing the updated SSO configuration, including:

        - ``org_id``: Unique organization identifier
        - ``org_name``: Organization name
        - ``IdP_name``: Identity Provider name
        - ``IdP_login_URL``: IdP SSO login URL
        - ``IdP_cert``: Updated list of IdP certificates
        - ``SP_cert``: Silo Service Provider certificate
        - ``SP_entity_id``: SAML SP entity ID URL
        - ``a8_portal_url``: Silo Access Portal URL for the org
        - ``a8_postback_url``: SAML ACS postback URL
        - ``partner_SSO_sign_cert``: Partner SSO signing certificate

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

    Example:
        >>> # Update IdP login URL only
        >>> updated = api.update_partner_sso_config(
        ...     org_name="sso_org",
        ...     idp_login_url="https://new-idp.example.com/sso",
        ... )
        >>>
        >>> # Add a new certificate without removing existing ones
        >>> current = api.get_partner_sso_config("sso_org")
        >>> new_certs = current["IdP_cert"] + [{
        ...     "name": "new_cert",
        ...     "cert": "-----BEGIN CERTIFICATE-----...",
        ... }]
        >>> api.update_partner_sso_config("sso_org", idp_cert=new_certs)
    """
    if not org_name or not isinstance(org_name, str) or not org_name.strip():
        raise ValidationError("Organization name must be a non-empty string")

    payload_data: dict[str, Any] = {
        "command": "update_partner_sso_config",
        "org_name": org_name,
    }

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

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

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

    if parent_org_id is not None:
        if not isinstance(parent_org_id, str):
            raise ValidationError("Parent organization ID must be a string")
        payload_data["parent_org_id"] = parent_org_id

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

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

    if idp_login_url is not None:
        if not isinstance(idp_login_url, str):
            raise ValidationError("IdP login URL must be a string")
        payload_data["IdP_login_URL"] = idp_login_url

    if idp_cert is not None:
        if not isinstance(idp_cert, list):
            raise ValidationError("IdP certificates must be a list")
        for cert in idp_cert:
            if (
                not isinstance(cert, dict)
                or "name" not in cert
                or "cert" not in cert
            ):
                raise ValidationError(
                    "Each certificate must have 'name' and 'cert' fields"
                )
        payload_data["IdP_cert"] = idp_cert

    payload = [payload_data]

    try:
        self.logger.debug(
            "Updating SSO configuration for organization: %s", org_name
        )
        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 updated SSO configuration for: %s", org_name
            )
            return result

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

    except Exception as e:
        if isinstance(e, OrgManagementAPIError):
            raise
        raise OrgManagementAPIError(
            f"Failed to update SSO configuration: {e}"
        ) from e

delete_partner_sso_config

delete_partner_sso_config(org_name: str, org_id: str | None = None) -> bool

Delete a Partner SSO configuration and its associated organization.

Permanently deletes the Partner SSO configuration for the specified organization. This also deletes the partner user and the organization itself, along with all associated data. This action cannot be undone.

Parameters:

Name Type Description Default
org_name str

Name of the organization (case sensitive).

required
org_id str | None

Optional organization ID for additional validation.

None

Returns:

Type Description
bool

True if the configuration was deleted successfully, False otherwise.

Raises:

Type Description
OrgManagementAPIError

If the API request fails

ValidationError

If parameters are invalid

Example

success = api.delete_partner_sso_config("sso_org") if success: ... print("SSO config, partner user, and org permanently deleted")

Source code in silo_sdk/management/org_api.py
@api_tag(MethodType.API_COMMAND, api_command="delete_partner_sso_config")
def delete_partner_sso_config(
    self,
    org_name: str,
    org_id: str | None = None,
) -> bool:
    """Delete a Partner SSO configuration and its associated organization.

    Permanently deletes the Partner SSO configuration for the specified
    organization. **This also deletes the partner user and the organization
    itself, along with all associated data. This action cannot be undone.**

    Args:
        org_name: Name of the organization (case sensitive).
        org_id: Optional organization ID for additional validation.

    Returns:
        True if the configuration was deleted successfully, False otherwise.

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

    Example:
        >>> success = api.delete_partner_sso_config("sso_org")
        >>> if success:
        ...     print("SSO config, partner user, and org permanently deleted")
    """
    if not org_name or not isinstance(org_name, str) or not org_name.strip():
        raise ValidationError("Organization name must be a non-empty string")

    payload_data = {
        "command": "delete_partner_sso_config",
        "org_name": org_name,
    }

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

    payload = [payload_data]

    try:
        self.logger.debug(
            "Deleting SSO configuration for organization: %s", org_name
        )
        response = self._make_api_request("POST", "api/", self.auth_token, payload)

        result = self._extract_api_result(response)
        if isinstance(result, dict):
            if result.get("deleted") == 1 and result.get("status") == 1:
                self.logger.info(
                    "Successfully deleted SSO configuration for: %s", org_name
                )
                return True
        elif result == 1:
            self.logger.info(
                "Successfully deleted SSO configuration for: %s", org_name
            )
            return True

        self.logger.warning("SSO config deletion may have failed: %s", result)
        return False

    except Exception as e:
        if isinstance(e, OrgManagementAPIError):
            raise
        raise OrgManagementAPIError(
            f"Failed to delete SSO configuration: {e}"
        ) from e

get_org_tree

get_org_tree(org_name: str, max_depth: int = 5) -> dict[str, Any]

Return the full org hierarchy as a nested dict.

Recursively traverses the organization hierarchy starting at org_name using :meth:get_org_children and builds a nested tree structure. Each node includes the org name, current user count, and a list of child nodes.

Parameters:

Name Type Description Default
org_name str

Root organization name to start traversal from.

required
max_depth int

Maximum number of levels to descend into the org hierarchy (default: 5). Set to 1 to return only the root node with no children.

5

Returns:

Type Description
dict[str, Any]

Nested dict representing the org hierarchy::

{ "org_name": "MyOrg", "org_id": "abc123", "current_users": 42, "children": [ { "org_name": "MyOrg/Child", "org_id": "def456", "current_users": 10, "children": [...] } ] }

dict[str, Any]

org_id is None for a node if its org info could not be

dict[str, Any]

determined — either the lookup failed, or the response simply

dict[str, Any]

didn't include an org_id. Unlike org_name, org_id is

dict[str, Any]

a stable identifier unaffected by bare-name collisions between

dict[str, Any]

sibling/cousin orgs elsewhere in the tree.

Raises:

Type Description
OrgManagementAPIError

If the API request fails

ValidationError

If parameters are invalid

Example

tree = api.get_org_tree("my_company", max_depth=3) print(tree["org_name"], tree["current_users"]) for child in tree["children"]: ... print(" ->", child["org_name"])

Source code in silo_sdk/management/org_api.py
@api_tag(MethodType.CONVENIENCE, wraps=["get_org_children"])
def get_org_tree(self, org_name: str, max_depth: int = 5) -> dict[str, Any]:
    """Return the full org hierarchy as a nested dict.

    Recursively traverses the organization hierarchy starting at
    ``org_name`` using :meth:`get_org_children` and builds a nested tree
    structure. Each node includes the org name, current user count, and a
    list of child nodes.

    Args:
        org_name: Root organization name to start traversal from.
        max_depth: Maximum number of levels to descend into the org
            hierarchy (default: 5). Set to 1 to return only the root
            node with no children.

    Returns:
        Nested dict representing the org hierarchy::

            {
                "org_name": "MyOrg",
                "org_id": "abc123",
                "current_users": 42,
                "children": [
                    {
                        "org_name": "MyOrg/Child",
                        "org_id": "def456",
                        "current_users": 10,
                        "children": [...]
                    }
                ]
            }

        ``org_id`` is ``None`` for a node if its org info could not be
        determined — either the lookup failed, or the response simply
        didn't include an ``org_id``. Unlike ``org_name``, ``org_id`` is
        a stable identifier unaffected by bare-name collisions between
        sibling/cousin orgs elsewhere in the tree.

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

    Example:
        >>> tree = api.get_org_tree("my_company", max_depth=3)
        >>> print(tree["org_name"], tree["current_users"])
        >>> for child in tree["children"]:
        ...     print("  ->", child["org_name"])
    """
    if not org_name or not isinstance(org_name, str) or not org_name.strip():
        raise ValidationError("Organization name must be a non-empty string")
    if max_depth < 1:
        raise ValidationError("max_depth must be at least 1")

    # Deferred import to avoid a module-level dependency of org_api.py
    # (a core API class) on the utils layer; also matches the existing
    # deferred-import convention used elsewhere in the SDK to sidestep
    # import-order/circularity concerns.
    from silo_sdk.utils.org import _walk_org_tree_bfs

    try:
        self.logger.debug(
            "Building org tree for %s (max_depth=%d)", org_name, max_depth
        )

        # _walk_org_tree_bfs is the same shared BFS/cycle-guard engine
        # used by list_users_recursive/get_org_usage_report/
        # get_user_usage_report (see issue #47) — this consumes the
        # (name, path, parent_path) form directly, rather than the
        # flat _collect_org_paths wrapper, because rebuilding this
        # nested tree shape needs the parent link. Previously this
        # traversal had no cycle guard at all, so a malformed/cyclic
        # get_org_children response could produce duplicate nodes.
        nodes: dict[str, dict[str, Any]] = {}
        root_node: dict[str, Any] | None = None

        for name, path, parent_path in _walk_org_tree_bfs(
            self, org_name, max_depth, caller="get_org_tree"
        ):
            node: dict[str, Any] = {
                "org_name": name,
                "org_id": None,
                "current_users": 0,
                "children": [],
            }

            # Fetch org info for user count (org_id comes along for
            # free). Look up by the full path, not the bare name: a
            # bare name that collides elsewhere in the tree is
            # ambiguous to the platform and the lookup fails. The
            # node's own org_name stays bare — nesting already gives
            # callers the path context.
            try:
                org_info = self.get_org(path)
                node["org_id"] = org_info.get("org_id")
                node["current_users"] = org_info.get(
                    "current_users", org_info.get("user_count", 0)
                )
            except Exception as e:
                logger.debug("get_org_tree: skipping node %s: %s", path, e)

            nodes[path] = node
            if parent_path is None:
                root_node = node
            else:
                parent_node = nodes.get(parent_path)
                if parent_node is not None:
                    parent_node["children"].append(node)

        if root_node is None:  # pragma: no cover — root is always yielded first
            raise OrgManagementAPIError("Failed to build org tree: root missing")

        self.logger.info("Built org tree for %s", org_name)
        return root_node
    except Exception as e:  # pragma: no cover
        if isinstance(e, (OrgManagementAPIError, ValidationError)):
            raise
        raise OrgManagementAPIError(f"Failed to build org tree: {e}") from e

get_proxy_policy

get_proxy_policy(org_name: str, org_id: str | None = None) -> list[dict[str, Any]]

Retrieve the proxy objects defined for an organization.

Returns the list of proxy server entries currently configured for the organization. Proxy objects are inherited and aggregated down the org hierarchy — child orgs see their own proxies plus those of all parents.

Parameters:

Name Type Description Default
org_name str

Organization name to retrieve proxy policy for.

required
org_id str | None

Optional organization ID for additional validation.

None

Returns:

Type Description
list[dict[str, Any]]

List of proxy object dicts. Each dict has a name field and a

list[dict[str, Any]]

location sub-object with address (required), port (optional),

list[dict[str, Any]]

and type (optional). Returns an empty list if none configured.

Raises:

Type Description
OrgManagementAPIError

If the API request fails

ValidationError

If parameters are invalid

Example

proxies = api.get_proxy_policy("my_org") for proxy in proxies: ... loc = proxy["location"] ... print(f"{proxy['name']}: {loc['address']}")

Source code in silo_sdk/management/org_api.py
@api_tag(MethodType.API_COMMAND, api_command="policy_proxies.get")
def get_proxy_policy(
    self,
    org_name: str,
    org_id: str | None = None,
) -> list[dict[str, Any]]:
    """Retrieve the proxy objects defined for an organization.

    Returns the list of proxy server entries currently configured for the
    organization. Proxy objects are inherited and aggregated down the org
    hierarchy — child orgs see their own proxies plus those of all parents.

    Args:
        org_name: Organization name to retrieve proxy policy for.
        org_id: Optional organization ID for additional validation.

    Returns:
        List of proxy object dicts. Each dict has a name field and a
        location sub-object with address (required), port (optional),
        and type (optional). Returns an empty list if none configured.

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

    Example:
        >>> proxies = api.get_proxy_policy("my_org")
        >>> for proxy in proxies:
        ...     loc = proxy["location"]
        ...     print(f"{proxy['name']}: {loc['address']}")
    """
    if not org_name or not isinstance(org_name, str) or not org_name.strip():
        raise ValidationError("Organization name must be a non-empty string")

    payload_data: dict[str, Any] = {
        "command": "policy_proxies.get",
        "org_name": org_name,
        "include_proxy_policy": True,
    }
    if org_id is not None:
        payload_data["org_id"] = org_id

    payload = [payload_data]

    try:
        self.logger.debug("Retrieving proxy policy for organization: %s", org_name)
        response = self._make_api_request("POST", "api/", self.auth_token, payload)
        result = self._extract_api_result(response)
        proxies = result if isinstance(result, list) else []
        self.logger.info(
            "Retrieved %d proxy objects for organization: %s",
            len(proxies),
            org_name,
        )
        return proxies
    except Exception as e:
        if isinstance(e, OrgManagementAPIError):
            raise
        raise OrgManagementAPIError(f"Failed to get proxy policy: {e}") from e

set_proxy_policy

set_proxy_policy(org_name: str, proxies: list[dict[str, Any]], org_id: str | None = None) -> list[dict[str, Any]]

Replace the proxy policy for an organization.

Fully overwrites the existing proxy list for the organization with the provided list. This operates at the current org level only — it does not affect parent or child org proxy configurations.

Each proxy object requires a unique name and a location.address (IP address or FQDN). Port and type are optional.

Note

Proxy object schema::

{
    "name": "eng_proxy_1",       # required, must be unique
    "location": {
        "address": "131.131.131.131",  # required, IP or FQDN
        "port": 8080,                  # optional
        "type": "http"                 # optional: http (default),
    }                                  # https, socks, socks4, socks5
}

Supported type values and their default ports:

  • http (default) — port 80
  • https — port 443; address must match SSL cert CN or users see ERR_PROXY_CERTIFICATE_INVALID
  • socks / socks4 — port 1080; no proxy auth supported
  • socks5 — port 1080; no auth supported in Chrome

Parameters:

Name Type Description Default
org_name str

Organization name to update proxy policy for.

required
proxies list[dict[str, Any]]

List of proxy object dicts. An empty list clears all proxies.

required
org_id str | None

Optional organization ID for additional validation.

None

Returns:

Type Description
list[dict[str, Any]]

The updated proxy list as returned by the API.

Raises:

Type Description
OrgManagementAPIError

If the API request fails

ValidationError

If parameters are invalid

Example

updated = api.set_proxy_policy("my_org", [ ... {"name": "corp_proxy", "location": {"address": "10.0.0.1", "port": 8080}}, ... {"name": "backup_proxy", "location": {"address": "proxy.corp.com"}}, ... ])

Source code in silo_sdk/management/org_api.py
@api_tag(MethodType.API_COMMAND, api_command="policy_proxies.set")
def set_proxy_policy(
    self,
    org_name: str,
    proxies: list[dict[str, Any]],
    org_id: str | None = None,
) -> list[dict[str, Any]]:
    """Replace the proxy policy for an organization.

    Fully overwrites the existing proxy list for the organization with the
    provided list. This operates at the current org level only — it does
    not affect parent or child org proxy configurations.

    Each proxy object requires a unique ``name`` and a ``location.address``
    (IP address or FQDN). Port and type are optional.

    Note:
        Proxy object schema::

            {
                "name": "eng_proxy_1",       # required, must be unique
                "location": {
                    "address": "131.131.131.131",  # required, IP or FQDN
                    "port": 8080,                  # optional
                    "type": "http"                 # optional: http (default),
                }                                  # https, socks, socks4, socks5
            }

        Supported ``type`` values and their default ports:

        - ``http`` (default) — port 80
        - ``https`` — port 443; **address must match SSL cert CN** or users
          see ``ERR_PROXY_CERTIFICATE_INVALID``
        - ``socks`` / ``socks4`` — port 1080; no proxy auth supported
        - ``socks5`` — port 1080; no auth supported in Chrome

    Args:
        org_name: Organization name to update proxy policy for.
        proxies: List of proxy object dicts. An empty list clears all proxies.
        org_id: Optional organization ID for additional validation.

    Returns:
        The updated proxy list as returned by the API.

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

    Example:
        >>> updated = api.set_proxy_policy("my_org", [
        ...     {"name": "corp_proxy", "location": {"address": "10.0.0.1", "port": 8080}},
        ...     {"name": "backup_proxy", "location": {"address": "proxy.corp.com"}},
        ... ])
    """
    if not org_name or not isinstance(org_name, str) or not org_name.strip():
        raise ValidationError("Organization name must be a non-empty string")
    if not isinstance(proxies, list):
        raise ValidationError("proxies must be a list of proxy objects")

    payload_data: dict[str, Any] = {
        "command": "policy_proxies.set",
        "org_name": org_name,
        "proxies": proxies,
    }
    if org_id is not None:
        payload_data["org_id"] = org_id

    payload = [payload_data]

    try:
        self.logger.debug(
            "Setting proxy policy for organization: %s (%d proxies)",
            org_name,
            len(proxies),
        )
        response = self._make_api_request("POST", "api/", self.auth_token, payload)
        result = self._extract_api_result(response)
        self.logger.info(
            "Successfully set proxy policy for organization: %s", org_name
        )
        return result if isinstance(result, list) else []
    except Exception as e:
        if isinstance(e, OrgManagementAPIError):
            raise
        raise OrgManagementAPIError(f"Failed to set proxy policy: {e}") from e

add_proxy_policy

add_proxy_policy(org_name: str, proxies: list[dict[str, Any]], org_id: str | None = None) -> str

Append proxy objects to an organization's proxy policy.

Adds one or more proxy entries to the existing proxy list without replacing the existing entries. Proxy names must be unique within the org — adding a proxy with a name that already exists will cause an error.

See :meth:set_proxy_policy for the proxy object schema and supported type values.

Parameters:

Name Type Description Default
org_name str

Organization name to add proxies to.

required
proxies list[dict[str, Any]]

List of proxy object dicts to append. Multiple proxy objects can be added in a single call by including them all in this list.

required
org_id str | None

Optional organization ID for additional validation.

None

Returns:

Type Description
str

Confirmation message string from the API.

Raises:

Type Description
OrgManagementAPIError

If the API request fails

ValidationError

If parameters are invalid

Example

Add multiple proxies in a single call

msg = api.add_proxy_policy("my_org", [ ... {"name": "proxy1", "location": {"address": "10.0.1.1", "port": 3128}}, ... {"name": "proxy2", "location": {"address": "backup.corp.com"}}, ... ]) print(msg)

Source code in silo_sdk/management/org_api.py
@api_tag(MethodType.API_COMMAND, api_command="policy_proxies.add")
def add_proxy_policy(
    self,
    org_name: str,
    proxies: list[dict[str, Any]],
    org_id: str | None = None,
) -> str:
    """Append proxy objects to an organization's proxy policy.

    Adds one or more proxy entries to the existing proxy list without
    replacing the existing entries. Proxy names must be unique within
    the org — adding a proxy with a name that already exists will cause
    an error.

    See :meth:`set_proxy_policy` for the proxy object schema and
    supported ``type`` values.

    Args:
        org_name: Organization name to add proxies to.
        proxies: List of proxy object dicts to append. **Multiple proxy
            objects can be added in a single call** by including them all
            in this list.
        org_id: Optional organization ID for additional validation.

    Returns:
        Confirmation message string from the API.

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

    Example:
        >>> # Add multiple proxies in a single call
        >>> msg = api.add_proxy_policy("my_org", [
        ...     {"name": "proxy1", "location": {"address": "10.0.1.1", "port": 3128}},
        ...     {"name": "proxy2", "location": {"address": "backup.corp.com"}},
        ... ])
        >>> print(msg)
    """
    if not org_name or not isinstance(org_name, str) or not org_name.strip():
        raise ValidationError("Organization name must be a non-empty string")
    if not isinstance(proxies, list) or not proxies:
        raise ValidationError("proxies must be a non-empty list of proxy objects")

    payload_data: dict[str, Any] = {
        "command": "policy_proxies.add",
        "org_name": org_name,
        "proxies": proxies,
    }
    if org_id is not None:
        payload_data["org_id"] = org_id

    payload = [payload_data]

    try:
        self.logger.debug(
            "Adding %d proxy object(s) to organization: %s",
            len(proxies),
            org_name,
        )
        response = self._make_api_request("POST", "api/", self.auth_token, payload)
        result = self._extract_api_result(response)
        self.logger.info(
            "Successfully added proxy objects to organization: %s", org_name
        )
        return str(result)
    except Exception as e:
        if isinstance(e, OrgManagementAPIError):
            raise
        raise OrgManagementAPIError(f"Failed to add proxy policy: {e}") from e

delete_proxy_policy

delete_proxy_policy(org_name: str, proxy_names: list[str], org_id: str | None = None) -> dict[str, Any]

Remove named proxy objects from an organization's proxy policy.

Deletes one or more proxy entries by name. Always returns HTTP 200 with a result dict — no error is raised if a name is not found. Check proxies_not_found in the result to detect missing names.

Parameters:

Name Type Description Default
org_name str

Organization name to remove proxies from.

required
proxy_names list[str]

List of proxy names to delete. Multiple names may be provided in a single call.

required
org_id str | None

Optional organization ID for additional validation.

None

Returns:

Type Description
dict[str, Any]

Dictionary with two keys:

dict[str, Any]
  • proxies_deleted (list): Names successfully removed.
dict[str, Any]
  • proxies_not_found (list): Names that did not exist (no error raised — callers should check this field explicitly).

Raises:

Type Description
OrgManagementAPIError

If the API request fails

ValidationError

If parameters are invalid

Example

result = api.delete_proxy_policy("my_org", ["proxy1", "proxy2"]) print(result["proxies_deleted"]) # ["proxy1", "proxy2"] print(result["proxies_not_found"]) # [] or names that were missing

Source code in silo_sdk/management/org_api.py
@api_tag(MethodType.API_COMMAND, api_command="policy_proxies.delete")
def delete_proxy_policy(
    self,
    org_name: str,
    proxy_names: list[str],
    org_id: str | None = None,
) -> dict[str, Any]:
    """Remove named proxy objects from an organization's proxy policy.

    Deletes one or more proxy entries by name. Always returns HTTP 200
    with a result dict — no error is raised if a name is not found.
    Check ``proxies_not_found`` in the result to detect missing names.

    Args:
        org_name: Organization name to remove proxies from.
        proxy_names: List of proxy names to delete. Multiple names may be
            provided in a single call.
        org_id: Optional organization ID for additional validation.

    Returns:
        Dictionary with two keys:

        - ``proxies_deleted`` (list): Names successfully removed.
        - ``proxies_not_found`` (list): Names that did not exist (no error
            raised — callers should check this field explicitly).

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

    Example:
        >>> result = api.delete_proxy_policy("my_org", ["proxy1", "proxy2"])
        >>> print(result["proxies_deleted"])    # ["proxy1", "proxy2"]
        >>> print(result["proxies_not_found"])  # [] or names that were missing
    """
    if not org_name or not isinstance(org_name, str) or not org_name.strip():
        raise ValidationError("Organization name must be a non-empty string")
    if not isinstance(proxy_names, list) or not proxy_names:
        raise ValidationError("proxy_names must be a non-empty list of strings")

    payload_data: dict[str, Any] = {
        "command": "policy_proxies.delete",
        "org_name": org_name,
        "proxy_names": proxy_names,
    }
    if org_id is not None:
        payload_data["org_id"] = org_id

    payload = [payload_data]

    try:
        self.logger.debug(
            "Deleting %d proxy object(s) from organization: %s",
            len(proxy_names),
            org_name,
        )
        response = self._make_api_request("POST", "api/", self.auth_token, payload)
        result = self._extract_api_result(response)
        if isinstance(result, dict):
            deleted = result.get("proxies_deleted", [])
            not_found = result.get("proxies_not_found", [])
            self.logger.info(
                "Proxy delete for %s: deleted=%s, not_found=%s",
                org_name,
                deleted,
                not_found,
            )
            return result
        return {"proxies_deleted": [], "proxies_not_found": proxy_names}
    except Exception as e:
        if isinstance(e, OrgManagementAPIError):
            raise
        raise OrgManagementAPIError(f"Failed to delete proxy policy: {e}") from e