From eb629e4f2a06bfd58c2e16fedac233cc79608677 Mon Sep 17 00:00:00 2001 From: Cecilia Stevens <63068179+ceciliastevens@users.noreply.github.com> Date: Mon, 2 Jun 2025 16:33:53 -0400 Subject: [PATCH 1/4] INTEG-2939 - add orgs --- docs/cli/cmds/orgs.md | 6 + docs/sdk/clients/orgs.md | 5 + mkdocs.yml | 2 + src/_incydr_cli/cmds/orgs.py | 193 +++++++++++++++++++++++++++++++++ src/_incydr_cli/main.py | 2 + src/_incydr_sdk/core/client.py | 12 ++ src/_incydr_sdk/orgs/client.py | 128 ++++++++++++++++++++++ src/_incydr_sdk/orgs/models.py | 130 ++++++++++++++++++++++ tests/test_orgs.py | 157 +++++++++++++++++++++++++++ 9 files changed, 635 insertions(+) create mode 100644 docs/cli/cmds/orgs.md create mode 100644 docs/sdk/clients/orgs.md create mode 100644 src/_incydr_cli/cmds/orgs.py create mode 100644 src/_incydr_sdk/orgs/client.py create mode 100644 src/_incydr_sdk/orgs/models.py create mode 100644 tests/test_orgs.py diff --git a/docs/cli/cmds/orgs.md b/docs/cli/cmds/orgs.md new file mode 100644 index 0000000..d80267d --- /dev/null +++ b/docs/cli/cmds/orgs.md @@ -0,0 +1,6 @@ +# Orgs Commands + +::: mkdocs-click + :module: _incydr_cli.cmds.orgs + :command: orgs + :list_subcommands: diff --git a/docs/sdk/clients/orgs.md b/docs/sdk/clients/orgs.md new file mode 100644 index 0000000..ed9e327 --- /dev/null +++ b/docs/sdk/clients/orgs.md @@ -0,0 +1,5 @@ +# Orgs + +::: _incydr_sdk.orgs.client.OrgsV1 + :docstring: + :members: diff --git a/mkdocs.yml b/mkdocs.yml index ce82a08..e13d6e1 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -54,6 +54,7 @@ nav: - File Events: 'sdk/clients/file_events.md' - Files: 'sdk/clients/files.md' - File Event Querying: 'sdk/clients/file_event_queries.md' + - Orgs: 'sdk/clients/orgs.md' - Sessions: 'sdk/clients/sessions.md' - Trusted Activites: 'sdk/clients/trusted_activities.md' - Users: 'sdk/clients/users.md' @@ -80,6 +81,7 @@ nav: - Directory Groups: 'cli/cmds/directory_groups.md' - File Events: 'cli/cmds/file_events.md' - Files: 'cli/cmds/files.md' + - Orgs: 'cli/cmds/orgs.md' - Sessions: 'cli/cmds/sessions.md' - Trusted Activites: 'cli/cmds/trusted_activities.md' - Users: 'cli/cmds/users.md' diff --git a/src/_incydr_cli/cmds/orgs.py b/src/_incydr_cli/cmds/orgs.py new file mode 100644 index 0000000..6f7beee --- /dev/null +++ b/src/_incydr_cli/cmds/orgs.py @@ -0,0 +1,193 @@ +from typing import Optional + +import click +from rich.panel import Panel + +from _incydr_cli import console +from _incydr_cli import logging_options +from _incydr_cli import render +from _incydr_cli.cmds.options.output_options import columns_option +from _incydr_cli.cmds.options.output_options import single_format_option +from _incydr_cli.cmds.options.output_options import SingleFormat +from _incydr_cli.cmds.options.output_options import table_format_option +from _incydr_cli.cmds.options.output_options import TableFormat +from _incydr_cli.core import IncydrCommand +from _incydr_cli.core import IncydrGroup +from _incydr_sdk.core.client import Client +from _incydr_sdk.orgs.models import Org +from _incydr_sdk.utils import model_as_card + + +@click.group(cls=IncydrGroup) +@logging_options +def orgs(): + """View and manage orgs.""" + + +@orgs.command("activate", cls=IncydrCommand) +@click.argument("org_guid") +@logging_options +def activate_org(org_guid: str): + """ + Activate the given org. + """ + client = Client() + client.orgs.v1.activate(org_guid) + console.print(f"Org '{org_guid}' successfully activated.") + + +@orgs.command("list", cls=IncydrCommand) +@click.option( + "--active/--inactive", + default=None, + help="Filter by active or inactive orgs. Defaults to returning both when when neither option is passed.", +) +@table_format_option +@columns_option +@logging_options +def list_( + active: Optional[bool], + format_: TableFormat, + columns: Optional[str], +): + """ + List orgs. + """ + client = Client() + orgs_ = client.orgs.v1.list(active=active).orgs + + if format_ == TableFormat.csv: + render.csv(Org, orgs_, columns=columns, flat=True) + elif format_ == TableFormat.table: + render.table(Org, orgs_, columns=columns, flat=False) + elif format_ == TableFormat.json_pretty: + for item in orgs_: + console.print_json(item.json()) + else: + for item in orgs_: + click.echo(item.json()) + + +@orgs.command("create", cls=IncydrCommand) +@click.argument("name") +@click.option( + "--external-reference", + default=None, + help="The external reference string for the org. Defaults to None.", +) +@click.option( + "--notes", + default=None, + help="The notes string for the org. Defaults to None.", +) +@click.option( + "--parent-org-guid", + default=None, + help="The org guid for the created org's parent. Defaults to your tenant's parent org.", +) +@single_format_option +@columns_option +@logging_options +def create( + name: str, + external_reference: Optional[str], + notes: Optional[str], + parent_org_guid: Optional[str], + format_: TableFormat, + columns: Optional[str], +): + """ + Create a new org. + """ + client = Client() + org = client.orgs.v1.create( + org_name=name, + org_ext_ref=external_reference, + parent_org_guid=parent_org_guid, + notes=notes, + ) + + if format_ == SingleFormat.rich and client.settings.use_rich: + console.print(Panel.fit(model_as_card(org), title=f"Org {org.org_name}")) + elif format_ == SingleFormat.json_pretty: + console.print_json(org.json()) + else: + click.echo(org.json()) + + +@orgs.command("deactivate", cls=IncydrCommand) +@click.argument("org_guid") +@logging_options +def deactivate_org(org_guid: str): + """ + Deactivate the given org. + """ + client = Client() + client.orgs.v1.deactivate(org_guid) + console.print(f"Org '{org_guid}' successfully deactivated.") + + +@orgs.command("show", cls=IncydrCommand) +@click.argument("org_guid") +@single_format_option +@columns_option +@logging_options +def show( + org_guid: str, + format_: TableFormat, + columns: Optional[str], +): + """ + View details of an org. + """ + client = Client() + org = client.orgs.v1.get_org(org_guid=org_guid) + + if format_ == SingleFormat.rich and client.settings.use_rich: + console.print(Panel.fit(model_as_card(org), title=f"Org: {org.org_name}")) + elif format_ == SingleFormat.json_pretty: + console.print_json(org.json()) + else: + click.echo(org.json()) + + +@orgs.command("update", cls=IncydrCommand) +@click.argument("org_guid") +@click.option( + "--name", help="The name of the org being updated. Required.", required=True +) +@click.option( + "--external-reference", + default=None, + help="The external reference string for the org. Defaults to None.", +) +@click.option( + "--notes", + default=None, + help="The notes string for the org. Defaults to None.", +) +@single_format_option +@columns_option +@logging_options +def update( + org_guid: str, + name: str, + external_reference: Optional[str], + notes: Optional[str], + format_: TableFormat, + columns: Optional[str], +): + """ + Update an org. + """ + client = Client() + org = client.orgs.v1.update( + org_guid=org_guid, org_name=name, org_ext_ref=external_reference, notes=notes + ) + + if format_ == SingleFormat.rich and client.settings.use_rich: + console.print(Panel.fit(model_as_card(org), title=f"Org {org.org_name}")) + elif format_ == SingleFormat.json_pretty: + console.print_json(org.json()) + else: + click.echo(org.json()) diff --git a/src/_incydr_cli/main.py b/src/_incydr_cli/main.py index 8b28ae0..f210f15 100644 --- a/src/_incydr_cli/main.py +++ b/src/_incydr_cli/main.py @@ -19,6 +19,7 @@ from _incydr_cli.cmds.directory_groups import directory_groups from _incydr_cli.cmds.file_events import file_events from _incydr_cli.cmds.files import files as files_client +from _incydr_cli.cmds.orgs import orgs from _incydr_cli.cmds.risk_profiles import risk_profiles from _incydr_cli.cmds.sessions import sessions from _incydr_cli.cmds.trusted_activities import trusted_activities @@ -88,6 +89,7 @@ def incydr(version, python, script_dir): incydr.add_command(sessions) incydr.add_command(trusted_activities) incydr.add_command(users) +incydr.add_command(orgs) incydr.add_command(watchlists) if __name__ == "__main__": diff --git a/src/_incydr_sdk/core/client.py b/src/_incydr_sdk/core/client.py index 057006d..c2d8ab4 100644 --- a/src/_incydr_sdk/core/client.py +++ b/src/_incydr_sdk/core/client.py @@ -23,6 +23,7 @@ from _incydr_sdk.exceptions import AuthMissingError from _incydr_sdk.file_events.client import FileEventsClient from _incydr_sdk.files.client import FilesClient +from _incydr_sdk.orgs.client import OrgsClient from _incydr_sdk.risk_profiles.client import RiskProfiles from _incydr_sdk.sessions.client import SessionsClient from _incydr_sdk.trusted_activities.client import TrustedActivitiesClient @@ -108,6 +109,7 @@ def response_hook(response, *args, **kwargs): self._directory_groups = DirectoryGroupsClient(self) self._file_events = FileEventsClient(self) self._files = FilesClient(self) + self._orgs = OrgsClient(self) self._sessions = SessionsClient(self) self._trusted_activities = TrustedActivitiesClient(self) self._users = UsersClient(self) @@ -295,6 +297,16 @@ def files(self): """ return self._files + @property + def orgs(self): + """ + Property returning an ['OrgsClient'](../orgs) for interacting with `/v1/orgs` API endpoints. + Usage: + + >>> client.orgs.v1.list() + """ + return self._orgs + @property def sessions(self): """ diff --git a/src/_incydr_sdk/orgs/client.py b/src/_incydr_sdk/orgs/client.py new file mode 100644 index 0000000..5790b9f --- /dev/null +++ b/src/_incydr_sdk/orgs/client.py @@ -0,0 +1,128 @@ +from requests import Response + +from _incydr_sdk.orgs.models import Org +from _incydr_sdk.orgs.models import OrgsList + + +class OrgsClient: + def __init__(self, parent): + self._parent = parent + self._v1 = None + + @property + def v1(self): + if self._v1 is None: + self._v1 = OrgsV1(self._parent) + return self._v1 + + +class OrgsV1: + """Client for `/v1/orgs` endpoints. + + Usage example: + + >>> import incydr + >>> + >>> client = incydr.Client(**kwargs) + >>> client.orgs.v1.list_orgs() + """ + + def __init__(self, parent): + self._parent = parent + + def activate(self, org_guid: str) -> Response: + """ + Activate an org. + + **Parameters:** + + * **org_guid**: `str` (required) - The unique ID for the org. + + **Returns**: A `requests.Response` indicating success. + """ + return self._parent.session.post(f"/v1/orgs/{org_guid}/activate") + + def list(self, active: bool = None) -> OrgsList: + """ + List orgs. + + **Parameters:** + + * **active**: `bool` - Return only orgs matching this active state. Defaults to None, which will return both active and inactive orgs. + + **Returns**: An [`OrgsList`][orgslist-model] object. + """ + return OrgsList.parse_response( + self._parent.session.get("/v1/orgs", params={"active": active}) + ) + + def create( + self, + org_name: str, + org_ext_ref: str = None, + parent_org_guid: str = None, + notes: str = None, + ) -> Org: + """ + Create an org. + + **Parameters:** + + * **org_name**: `str` (required) - The name of the org to create. + * **org_ext_ref**: `str` - The external reference of the org to create. Defaults to None. + * **parent_org_guid**: `str` - The parent ID of the org to create. Defaults to None. + * **notes**: `str` - The notes of the org to create. Defaults to None. + pa + **Returns**: An [`Org`][org-model] object representing the created org. + """ + payload = { + "orgName": org_name, + "orgExtRef": org_ext_ref, + "parentOrgGuid": parent_org_guid, + "notes": notes, + } + return Org.parse_response(self._parent.session.post("/v1/orgs", json=payload)) + + def deactivate(self, org_guid: str) -> Response: + """ + Deactivate an org. + + **Parameters:** + + * **org_guid**: `str` (required) - The unique ID for the org. + + **Returns**: A `requests.Response` indicating success. + """ + return self._parent.session.post(f"/v1/orgs/{org_guid}/deactivate") + + def get_org(self, org_guid: str) -> Org: + """ + Get a specific organization. + + **Parameters**: + + * **org_guid**: `str` (required) - The unique ID for the org. + + **Returns**: An [`Org`][org-model] object representing the org. + """ + return Org.parse_response(self._parent.session.get(f"/v1/orgs/{org_guid}")) + + def update( + self, org_guid: str, org_name: str, org_ext_ref: str = None, notes: str = None + ) -> Org: + """ + Create an org. + + **Parameters:** + + * **org_guid**: `str` (required) - The unique ID for the org to update. + * **org_name**: `str` (required) - The name of the org to update. + * **org_ext_ref**: `str` - The external reference of the org to update. Defaults to None. + * **notes**: `str` - The notes of the org to update. Defaults to None. + + **Returns**: An [`Org`][org-model] object representing the created org. + """ + payload = {"orgName": org_name, "orgExtRef": org_ext_ref, "notes": notes} + return Org.parse_response( + self._parent.session.put(f"/v1/orgs/{org_guid}", json=payload) + ) diff --git a/src/_incydr_sdk/orgs/models.py b/src/_incydr_sdk/orgs/models.py new file mode 100644 index 0000000..f20b615 --- /dev/null +++ b/src/_incydr_sdk/orgs/models.py @@ -0,0 +1,130 @@ +from datetime import datetime +from typing import List +from typing import Optional + +from pydantic import Field +from pydantic import validator + +from _incydr_sdk.core.models import ResponseModel +from _incydr_sdk.queries.utils import parse_str_to_dt + + +# V1 org endpoints are inconsistent in their date formatting, so we need +# to use a parse method here. +def parse_datetime_validator(cls, value): + if value: + return parse_str_to_dt(value) + else: + return None + + +class Org(ResponseModel): + """ + A model representing an organization. + + **Fields**: + + * **org_guid: `str` - The globally unique ID of this org. + * **org_name: `str` - The name of this org. + * **org_ext_ref: `str` - Optional external reference information, such as a serial number, asset tag, employee ID, or help desk issue ID. + * **notes: `str` - The notes for this org. Intended for optional additional descriptive information. + * **parent_org_guid: `str` - The globally unique ID of the parent org. + * **active: `bool` - Whether or not the org is currently active. + * **creation_date: `datetime` - Date and time this org was created. + * **modification_date: `datetime` - Date and time this org was last modified. + * **deactivation_date: `datetime` - Date and time this org was deactivated. Blank if org is active. + * **registration_key: `str` - The registration key for the org. + * **user_count: `int` - The count of users within this org. + * **computer_count: `int` - The count of computers within this org. + * **org_count: `int` - The count of child orgs for this org. + """ + + org_guid: Optional[str] = Field( + None, + alias="orgGuid", + description="The globally unique ID of this org.", + ) + org_name: Optional[str] = Field( + None, + alias="orgName", + description="The name of this org.", + ) + org_ext_ref: Optional[str] = Field( + None, + alias="orgExtRef", + description="Optional external reference information, such as a serial number, asset tag, employee ID, or help desk issue ID.", + ) + notes: Optional[str] = Field( + None, + alias="notes", + description="The notes for this org. Intended for optional additional descriptive information.", + ) + parent_org_guid: Optional[str] = Field( + None, + alias="parentOrgGuid", + description="The globally unique ID of the parent org.", + ) + active: Optional[bool] = Field( + None, + description="Whether or not the org is currently active.", + ) + creation_date: Optional[datetime] = Field( + None, + alias="creationDate", + description="Date and time this org was created.", + ) + modification_date: Optional[datetime] = Field( + None, + alias="modificationDate", + description="Date and time this org was last modified.", + ) + deactivation_date: Optional[datetime] = Field( + None, + alias="deactivationDate", + description="Date and time this org was deactivated. Blank if org is active.", + ) + registration_key: Optional[str] = Field( + None, + alias="registrationKey", + description="The registration key for the org.", + ) + user_count: Optional[int] = Field( + None, + alias="userCount", + description="The count of users within this org.", + ) + computer_count: Optional[int] = Field( + None, + alias="computerCount", + description="The count of computers within this org.", + ) + org_count: Optional[int] = Field( + None, + alias="orgCount", + description="The count of child orgs for this org.", + ) + _creation_date = validator("creation_date", allow_reuse=True, pre=True)( + parse_datetime_validator + ) + _modification_date = validator("modification_date", allow_reuse=True, pre=True)( + parse_datetime_validator + ) + _deactivation_date = validator("deactivation_date", allow_reuse=True, pre=True)( + parse_datetime_validator + ) + + +class OrgsList(ResponseModel): + """ + A model representing a list of `Org` objects. + + **Fields**: + + * **orgs**: `List[Org]` - The list of orgs retrieved from the query. + * **total_count**: `int` - Total count of orgs found by query. + """ + + orgs: Optional[List[Org]] = Field(None, description="A list of orgs") + total_count: Optional[int] = Field( + None, alias="totalCount", description="The total number of orgs" + ) diff --git a/tests/test_orgs.py b/tests/test_orgs.py new file mode 100644 index 0000000..51f2517 --- /dev/null +++ b/tests/test_orgs.py @@ -0,0 +1,157 @@ +import pytest +from pytest_httpserver import HTTPServer + +from _incydr_cli.main import incydr +from incydr import Client + +TEST_ORG_NAME = "test org" +TEST_ORG_GUID = "orgguid" +TEST_DATA = { + "orgGuid": "7ed19049-3266-42ff-a3c9-b79a7f8e2b30", + "orgName": TEST_ORG_NAME, + "orgExtRef": None, + "notes": None, + "parentOrgGuid": "a10400e3-e7f3-406e-85f4-d6aa59d94828", + "active": True, + "creationDate": "2025-06-02T18:11:32.198Z", + "modificationDate": "2025-06-02T18:11:32.314Z", + "deactivationDate": None, + "registrationKey": "USRR-2C8M-CS28-C9WW", + "userCount": 0, + "computerCount": 0, + "orgCount": 0, +} +TEST_CREATE_ORG_PAYLOAD = { + "orgName": TEST_ORG_NAME, + "orgExtRef": None, + "notes": None, + "parentOrgGuid": None, +} +TEST_UPDATE_ORG_PAYLOAD = {"orgName": TEST_ORG_NAME, "orgExtRef": None, "notes": None} +TEST_ORG_LIST = {"totalCount": 1, "orgs": [TEST_DATA]} + + +@pytest.fixture +def mock_get_org(httpserver_auth: HTTPServer): + httpserver_auth.expect_request( + f"/v1/orgs/{TEST_ORG_GUID}", method="GET" + ).respond_with_json(response_json=TEST_DATA, status=200) + return httpserver_auth + + +@pytest.fixture +def mock_create_org(httpserver_auth: HTTPServer): + httpserver_auth.expect_request( + "/v1/orgs", method="POST", json=TEST_CREATE_ORG_PAYLOAD + ).respond_with_json(response_json=TEST_DATA, status=200) + return httpserver_auth + + +@pytest.fixture +def mock_update_org(httpserver_auth: HTTPServer): + httpserver_auth.expect_request( + f"/v1/orgs/{TEST_ORG_GUID}", method="PUT", json=TEST_UPDATE_ORG_PAYLOAD + ).respond_with_json(response_json=TEST_DATA, status=200) + return httpserver_auth + + +@pytest.fixture +def mock_list_orgs(httpserver_auth: HTTPServer): + httpserver_auth.expect_request("/v1/orgs", method="GET").respond_with_json( + response_json=TEST_ORG_LIST, status=200 + ) + return httpserver_auth + + +@pytest.fixture +def mock_activate_org(httpserver_auth: HTTPServer): + httpserver_auth.expect_request( + f"/v1/orgs/{TEST_ORG_GUID}/activate", method="POST" + ).respond_with_data(response_data="", status=200) + return httpserver_auth + + +@pytest.fixture +def mock_deactivate_org(httpserver_auth: HTTPServer): + httpserver_auth.expect_request( + f"/v1/orgs/{TEST_ORG_GUID}/deactivate", method="POST" + ).respond_with_data(response_data="", status=200) + return httpserver_auth + + +def test_activate_org_makes_correct_call(mock_activate_org): + c = Client() + c.orgs.v1.activate(TEST_ORG_GUID) + mock_activate_org.check() + + +def test_deactivate_org_makes_correct_call(mock_deactivate_org): + c = Client() + c.orgs.v1.deactivate(TEST_ORG_GUID) + mock_deactivate_org.check() + + +def test_create_org_makes_correct_call(mock_create_org): + c = Client() + response = c.orgs.v1.create(org_name=TEST_ORG_NAME) + assert response.org_name == TEST_ORG_NAME + mock_create_org.check() + + +def test_update_org_makes_correct_call(mock_update_org): + c = Client() + response = c.orgs.v1.update(org_guid=TEST_ORG_GUID, org_name=TEST_ORG_NAME) + assert response.org_name == TEST_ORG_NAME + mock_update_org.check() + + +def test_list_orgs_makes_correct_call(mock_list_orgs): + c = Client() + response = c.orgs.v1.list() + assert len(response.orgs) == 1 + mock_list_orgs.check() + + +# ************************************************ CLI ************************************************ + + +def test_cli_activate_activates_org(runner, mock_activate_org): + result = runner.invoke(incydr, ["orgs", "activate", TEST_ORG_GUID]) + assert result.exit_code == 0 + mock_activate_org.check() + + +def test_cli_list_lists_orgs(runner, mock_list_orgs): + result = runner.invoke(incydr, ["orgs", "list"]) + assert result.exit_code == 0 + assert TEST_ORG_NAME in result.output + mock_list_orgs.check() + + +def test_cli_create_creates_org(runner, mock_create_org): + result = runner.invoke(incydr, ["orgs", "create", TEST_ORG_NAME]) + assert result.exit_code == 0 + assert TEST_ORG_NAME in result.output + mock_create_org.check() + + +def test_cli_deactivate_deactivates_org(runner, mock_deactivate_org): + result = runner.invoke(incydr, ["orgs", "deactivate", TEST_ORG_GUID]) + assert result.exit_code == 0 + mock_deactivate_org.check() + + +def test_cli_show_shows_org(runner, mock_get_org): + result = runner.invoke(incydr, ["orgs", "show", TEST_ORG_GUID]) + assert result.exit_code == 0 + assert TEST_ORG_NAME in result.output + mock_get_org.check() + + +def test_cli_update_updates_org(runner, mock_update_org): + result = runner.invoke( + incydr, ["orgs", "update", TEST_ORG_GUID, "--name", TEST_ORG_NAME] + ) + assert result.exit_code == 0 + assert TEST_ORG_NAME in result.output + mock_update_org.check() From 6b4e9dca594072a8a2f2b3ab6b158c81b69a5b58 Mon Sep 17 00:00:00 2001 From: Cecilia Stevens <63068179+ceciliastevens@users.noreply.github.com> Date: Tue, 3 Jun 2025 13:47:50 -0400 Subject: [PATCH 2/4] INTEG-2940 - add legal-hold --- CHANGELOG.md | 5 + docs/cli/cmds/legal_hold.md | 6 + docs/sdk/clients/legal_hold.md | 5 + mkdocs.yml | 2 + src/_incydr_cli/cmds/legal_hold.py | 274 ++++++++++++++++++ src/_incydr_cli/cmds/orgs.py | 6 +- src/_incydr_cli/main.py | 2 + src/_incydr_sdk/core/client.py | 13 + src/_incydr_sdk/legal_hold/client.py | 349 +++++++++++++++++++++++ src/_incydr_sdk/legal_hold/models.py | 183 ++++++++++++ src/_incydr_sdk/orgs/models.py | 26 +- tests/test_legal_hold.py | 410 +++++++++++++++++++++++++++ 12 files changed, 1265 insertions(+), 16 deletions(-) create mode 100644 docs/cli/cmds/legal_hold.md create mode 100644 docs/sdk/clients/legal_hold.md create mode 100644 src/_incydr_cli/cmds/legal_hold.py create mode 100644 src/_incydr_sdk/legal_hold/client.py create mode 100644 src/_incydr_sdk/legal_hold/models.py create mode 100644 tests/test_legal_hold.py diff --git a/CHANGELOG.md b/CHANGELOG.md index dd1e5dc..dad7d01 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,11 @@ here. ## Unreleased +### Added + +- The `orgs` and `legal_hold` clients to the SDK. +- The `orgs` and `legal-hold` command groups to the CLI. + ## 2.4.0 - 2025-05-27 ### Added diff --git a/docs/cli/cmds/legal_hold.md b/docs/cli/cmds/legal_hold.md new file mode 100644 index 0000000..250da4c --- /dev/null +++ b/docs/cli/cmds/legal_hold.md @@ -0,0 +1,6 @@ +# Legal Hold Commands + +::: mkdocs-click + :module: _incydr_cli.cmds.legal_hold + :command: legal_hold + :list_subcommands: diff --git a/docs/sdk/clients/legal_hold.md b/docs/sdk/clients/legal_hold.md new file mode 100644 index 0000000..d3e0980 --- /dev/null +++ b/docs/sdk/clients/legal_hold.md @@ -0,0 +1,5 @@ +# Legal Hold + +::: _incydr_sdk.legal_hold.client.LegalHoldV1 + :docstring: + :members: diff --git a/mkdocs.yml b/mkdocs.yml index e13d6e1..5bc4468 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -54,6 +54,7 @@ nav: - File Events: 'sdk/clients/file_events.md' - Files: 'sdk/clients/files.md' - File Event Querying: 'sdk/clients/file_event_queries.md' + - Legal Hold: 'sdk/clients/legal_hold.md' - Orgs: 'sdk/clients/orgs.md' - Sessions: 'sdk/clients/sessions.md' - Trusted Activites: 'sdk/clients/trusted_activities.md' @@ -81,6 +82,7 @@ nav: - Directory Groups: 'cli/cmds/directory_groups.md' - File Events: 'cli/cmds/file_events.md' - Files: 'cli/cmds/files.md' + - Legal Hold: 'cli/cmds/legal_hold.md' - Orgs: 'cli/cmds/orgs.md' - Sessions: 'cli/cmds/sessions.md' - Trusted Activites: 'cli/cmds/trusted_activities.md' diff --git a/src/_incydr_cli/cmds/legal_hold.py b/src/_incydr_cli/cmds/legal_hold.py new file mode 100644 index 0000000..addb29c --- /dev/null +++ b/src/_incydr_cli/cmds/legal_hold.py @@ -0,0 +1,274 @@ +from typing import Optional + +import click +from rich.panel import Panel + +from _incydr_cli import console +from _incydr_cli import logging_options +from _incydr_cli import render +from _incydr_cli.cmds.options.output_options import columns_option +from _incydr_cli.cmds.options.output_options import single_format_option +from _incydr_cli.cmds.options.output_options import SingleFormat +from _incydr_cli.cmds.options.output_options import table_format_option +from _incydr_cli.cmds.options.output_options import TableFormat +from _incydr_cli.core import IncydrCommand +from _incydr_cli.core import IncydrGroup +from _incydr_sdk.core.client import Client +from _incydr_sdk.legal_hold.models import Custodian +from _incydr_sdk.legal_hold.models import CustodianMatter +from _incydr_sdk.legal_hold.models import LegalHoldPolicy +from _incydr_sdk.legal_hold.models import Matter +from _incydr_sdk.utils import model_as_card + + +@click.group(cls=IncydrGroup) +@logging_options +def legal_hold(): + """View and manage legal holds.""" + + +@legal_hold.command("list-matters-for-user", cls=IncydrCommand) +@click.argument("user-id") +@table_format_option +@columns_option +@logging_options +def list_matters_for_user(user_id: str, format_: TableFormat, columns: Optional[str]): + """List the matter memberships for a specific user.""" + client = Client() + memberships_ = client.legal_hold.v1.iter_all_memberships_for_user(user_id=user_id) + + if format_ == TableFormat.csv: + render.csv(CustodianMatter, memberships_, columns=columns, flat=True) + elif format_ == TableFormat.table: + render.table(CustodianMatter, memberships_, columns=columns, flat=False) + elif format_ == TableFormat.json_pretty: + for item in memberships_: + console.print_json(item.json()) + else: + for item in memberships_: + click.echo(item.json()) + + +@legal_hold.command("list-custodians", cls=IncydrCommand) +@click.argument("matter-id") +@table_format_option +@columns_option +@logging_options +def list_custodians(matter_id: str, format_: TableFormat, columns: Optional[str]): + """List the matter memberships for a specific user.""" + client = Client() + memberships_ = client.legal_hold.v1.iter_all_custodians(matter_id=matter_id) + + if format_ == TableFormat.csv: + render.csv(Custodian, memberships_, columns=columns, flat=True) + elif format_ == TableFormat.table: + render.table(Custodian, memberships_, columns=columns, flat=False) + elif format_ == TableFormat.json_pretty: + for item in memberships_: + console.print_json(item.json()) + else: + for item in memberships_: + click.echo(item.json()) + + +@legal_hold.command("add-custodian", cls=IncydrCommand) +@click.option("--user-id", required=True, default=None, help="The user ID to add.") +@click.option( + "--matter-id", + required=True, + default=None, + help="The matter ID to which the user will be added", +) +@single_format_option +@logging_options +def add_custodian(user_id: str, matter_id: str, format_: SingleFormat): + """Add a custodian to a matter.""" + client = Client() + result = client.legal_hold.v1.add_custodian(user_id=user_id, matter_id=matter_id) + + if format_ == SingleFormat.rich and client.settings.use_rich: + console.print( + Panel.fit( + model_as_card(result), title=f"Membership: {result.custodian.username}" + ) + ) + elif format_ == SingleFormat.json_pretty: + console.print_json(result.json()) + else: + click.echo(result.json()) + + +@legal_hold.command("remove-custodian", cls=IncydrCommand) +@click.option("--user-id", required=True, default=None, help="The user ID to remove.") +@click.option( + "--matter-id", + required=True, + default=None, + help="The matter ID from which the user will be removed", +) +@logging_options +def remove_custodian( + user_id: str, + matter_id: str, +): + """Remove custodian from a matter.""" + client = Client() + client.legal_hold.v1.remove_custodian(user_id=user_id, matter_id=matter_id) + console.log(f"User {user_id} removed successfully from matter {matter_id}.") + + +@legal_hold.command("list-matters", cls=IncydrCommand) +@click.option( + "--creator-user-id", + default=None, + help="Find legal hold matters that were created by the user with this unique identifier.", +) +@click.option( + "--active/--inactive", + default=None, + help="Filter by active or inactive matters. Defaults to returning both when when neither option is passed.", +) +@click.option( + "--name", + default=None, + help="Find legal hold matters whose 'name' either equals or partially contains this value.", +) +@table_format_option +@columns_option +@logging_options +def list_matters( + creator_user_id: Optional[str], + active: Optional[bool], + name: Optional[str], + format_: TableFormat, + columns: Optional[str], +): + """List all matters.""" + client = Client() + result = client.legal_hold.v1.iter_all_matters( + creator_user_id=creator_user_id, active=active, name=name + ) + + if format_ == TableFormat.csv: + render.csv(Matter, result, columns=columns, flat=True) + elif format_ == TableFormat.table: + render.table(Matter, result, columns=columns, flat=False) + elif format_ == TableFormat.json_pretty: + for item in result: + console.print_json(item.json()) + else: + for item in result: + click.echo(item.json()) + + +@legal_hold.command("create-matter", cls=IncydrCommand) +@click.option( + "--policy-id", + required=True, + default=None, + help="The policy ID to be used by the created matter. Required.", +) +@click.option( + "--name", + required=True, + default=None, + help="The name of the matter to be created. Required.", +) +@click.option( + "--description", default=None, help="The description of the matter to be created." +) +@click.option("--notes", default=None, help="The notes for the matter to be created.") +@single_format_option +@logging_options +def create_matter( + policy_id: str, + name: str, + description: Optional[str], + notes: Optional[str], + format_: SingleFormat, +): + """Create a matter.""" + client = Client() + result = client.legal_hold.v1.create_matter( + policy_id=policy_id, name=name, description=description, notes=notes + ) + + if format_ == SingleFormat.rich and client.settings.use_rich: + console.print(Panel.fit(model_as_card(result), title=f"Matter: {result.name}")) + elif format_ == SingleFormat.json_pretty: + console.print_json(result.json()) + else: + click.echo(result.json()) + + +@legal_hold.command("deactivate-matter", cls=IncydrCommand) +@click.argument("matter-id") +@logging_options +def deactivate_matter(matter_id: str): + """Deactivate a matter.""" + client = Client() + client.legal_hold.v1.deactivate_matter(matter_id=matter_id) + console.log(f"Successfully deactivated {matter_id}") + + +@legal_hold.command("reactivate-matter", cls=IncydrCommand) +@click.argument("matter-id") +@logging_options +def reactivate_matter(matter_id: str): + """Reactivate a matter.""" + client = Client() + client.legal_hold.v1.reactivate_matter(matter_id=matter_id) + console.log(f"Successfully reactivated {matter_id}") + + +@legal_hold.command("show-matter", cls=IncydrCommand) +@click.argument("matter-id") +@single_format_option +@logging_options +def show_matter(matter_id: str, format_: SingleFormat): + """Show details for a matter.""" + client = Client() + result = client.legal_hold.v1.get_matter(matter_id=matter_id) + + if format_ == SingleFormat.rich and client.settings.use_rich: + console.print(Panel.fit(model_as_card(result), title=f"Matter: {result.name}")) + elif format_ == SingleFormat.json_pretty: + console.print_json(result.json()) + else: + click.echo(result.json()) + + +@legal_hold.command("list-policies", cls=IncydrCommand) +@table_format_option +@columns_option +@logging_options +def list_policies(format_: TableFormat, columns: Optional[str]): + client = Client() + result = client.legal_hold.v1.iter_all_policies() + + if format_ == TableFormat.csv: + render.csv(LegalHoldPolicy, result, columns=columns, flat=True) + elif format_ == TableFormat.table: + render.table(LegalHoldPolicy, result, columns=columns, flat=False) + elif format_ == TableFormat.json_pretty: + for item in result: + console.print_json(item.json()) + else: + for item in result: + click.echo(item.json()) + + +@legal_hold.command("show-policy", cls=IncydrCommand) +@click.argument("policy-id") +@single_format_option +@logging_options +def show_policy(policy_id: str, format_: SingleFormat): + client = Client() + result = client.legal_hold.v1.get_policy(policy_id=policy_id) + + if format_ == SingleFormat.rich and client.settings.use_rich: + console.print(Panel.fit(model_as_card(result), title=f"Policy: {result.name}")) + elif format_ == SingleFormat.json_pretty: + console.print_json(result.json()) + else: + click.echo(result.json()) diff --git a/src/_incydr_cli/cmds/orgs.py b/src/_incydr_cli/cmds/orgs.py index 6f7beee..acb581a 100644 --- a/src/_incydr_cli/cmds/orgs.py +++ b/src/_incydr_cli/cmds/orgs.py @@ -93,7 +93,7 @@ def create( external_reference: Optional[str], notes: Optional[str], parent_org_guid: Optional[str], - format_: TableFormat, + format_: SingleFormat, columns: Optional[str], ): """ @@ -134,7 +134,7 @@ def deactivate_org(org_guid: str): @logging_options def show( org_guid: str, - format_: TableFormat, + format_: SingleFormat, columns: Optional[str], ): """ @@ -174,7 +174,7 @@ def update( name: str, external_reference: Optional[str], notes: Optional[str], - format_: TableFormat, + format_: SingleFormat, columns: Optional[str], ): """ diff --git a/src/_incydr_cli/main.py b/src/_incydr_cli/main.py index f210f15..fbdf0fd 100644 --- a/src/_incydr_cli/main.py +++ b/src/_incydr_cli/main.py @@ -19,6 +19,7 @@ from _incydr_cli.cmds.directory_groups import directory_groups from _incydr_cli.cmds.file_events import file_events from _incydr_cli.cmds.files import files as files_client +from _incydr_cli.cmds.legal_hold import legal_hold from _incydr_cli.cmds.orgs import orgs from _incydr_cli.cmds.risk_profiles import risk_profiles from _incydr_cli.cmds.sessions import sessions @@ -90,6 +91,7 @@ def incydr(version, python, script_dir): incydr.add_command(trusted_activities) incydr.add_command(users) incydr.add_command(orgs) +incydr.add_command(legal_hold) incydr.add_command(watchlists) if __name__ == "__main__": diff --git a/src/_incydr_sdk/core/client.py b/src/_incydr_sdk/core/client.py index c2d8ab4..2fb49db 100644 --- a/src/_incydr_sdk/core/client.py +++ b/src/_incydr_sdk/core/client.py @@ -23,6 +23,7 @@ from _incydr_sdk.exceptions import AuthMissingError from _incydr_sdk.file_events.client import FileEventsClient from _incydr_sdk.files.client import FilesClient +from _incydr_sdk.legal_hold.client import LegalHoldClient from _incydr_sdk.orgs.client import OrgsClient from _incydr_sdk.risk_profiles.client import RiskProfiles from _incydr_sdk.sessions.client import SessionsClient @@ -109,6 +110,7 @@ def response_hook(response, *args, **kwargs): self._directory_groups = DirectoryGroupsClient(self) self._file_events = FileEventsClient(self) self._files = FilesClient(self) + self._legal_hold = LegalHoldClient(self) self._orgs = OrgsClient(self) self._sessions = SessionsClient(self) self._trusted_activities = TrustedActivitiesClient(self) @@ -297,6 +299,17 @@ def files(self): """ return self._files + @property + def legal_hold(self): + """ + Property returning a [`LegalHoldClient`](../legal_hold) for interacting with `/v1/legal-hold` API endpoints. + + Usage: + + >>> client.legal_hold.v1.iter_all_matters() + """ + return self._legal_hold + @property def orgs(self): """ diff --git a/src/_incydr_sdk/legal_hold/client.py b/src/_incydr_sdk/legal_hold/client.py new file mode 100644 index 0000000..19867a0 --- /dev/null +++ b/src/_incydr_sdk/legal_hold/client.py @@ -0,0 +1,349 @@ +from itertools import count +from typing import Iterator + +from requests import Response + +from _incydr_sdk.legal_hold.models import AddCustodianResponse +from _incydr_sdk.legal_hold.models import Custodian +from _incydr_sdk.legal_hold.models import CustodianMatter +from _incydr_sdk.legal_hold.models import CustodianMattersPage +from _incydr_sdk.legal_hold.models import CustodiansPage +from _incydr_sdk.legal_hold.models import LegalHoldPolicy +from _incydr_sdk.legal_hold.models import LegalHoldPolicyPage +from _incydr_sdk.legal_hold.models import Matter +from _incydr_sdk.legal_hold.models import MattersPage + + +class LegalHoldClient: + def __init__(self, parent): + self._parent = parent + self._v1 = None + + @property + def v1(self): + if self._v1 is None: + self._v1 = LegalHoldV1(self._parent) + return self._v1 + + +class LegalHoldV1: + """Client for `/v1/orgs` endpoints. + + Usage example: + + >>> import incydr + >>> + >>> client = incydr.Client(**kwargs) + >>> client.legal_hold.v1.get_matter("matter_id") + """ + + def __init__(self, parent): + self._parent = parent + + def get_memberships_page_for_user( + self, user_id: str, page_num: int = None, page_size: int = None + ) -> CustodianMattersPage: + """Get a page of matter memberships for a given user + + **Parameters:** + + * **user_id**: `str` (required) - The user ID for the user being queried. + * **page_num**: `int` - The page number to request. + * **page_size**: `int` - The page size to request + + **Returns**: A [`CustodianMattersPage`][custodianmatterspage-model] object with the page of memberships for that user. + """ + return CustodianMattersPage.parse_response( + self._parent.session.get( + f"/v1/legal-hold/custodians/{user_id}", + params={"page": page_num, "pageSize": page_size}, + ) + ) + + def iter_all_memberships_for_user( + self, user_id: str, page_size: int = None + ) -> Iterator[CustodianMatter]: + """Get all matter memberships for a given user + + **Parameters:** + + * **user_id**: `str` (required) - The user ID for the user being queried. + * **page_size**: `int` - The page size to request + + **Returns**: A generator object that yields [`CustodianMatter`][custodianmatter-model] objects with the memberships for the given user. + """ + page_size = page_size or self._parent.settings.page_size + for page_num in count(1): + page = self.get_memberships_page_for_user( + user_id=user_id, page_num=page_num, page_size=page_size + ) + yield from page.matters + if len(page.matters) < page_size: + break + + def get_custodians_page( + self, matter_id: str, page_num: int = None, page_size: int = None + ): + """Get a page of custodians for a given matter + + **Parameters:** + + * **matter_id**: `str` (required) - The matter ID being queried. + * **page_num**: `int` - The page number to request. + * **page_size**: `int` - The page size to request + + **Returns**: A [`CustodiansPage`][custodianspage-model] object with the page of memberships for that matter. + """ + return CustodiansPage.parse_response( + self._parent.session.get( + f"/v1/legal-hold/matters/{matter_id}/custodians", + params={"page": page_num, "pageSize": page_size}, + ) + ) + + def iter_all_custodians( + self, matter_id: str, page_size: int = None + ) -> Iterator[Custodian]: + """Get all custodians for a given matter + + **Parameters:** + + * **matter_id**: `str` (required) - The matter ID being queried. + * **page_size**: `int` - The page size to request + + **Returns**: A generator object that yields [`Custodian`][custodian-model] objects with the memberships for the given matter. + """ + page_size = page_size or self._parent.settings.page_size + for page_num in count(1): + page = self.get_custodians_page( + matter_id=matter_id, page_num=page_num, page_size=page_size + ) + yield from page.custodians + if len(page.custodians) < page_size: + break + + def add_custodian(self, matter_id: str, user_id: str) -> AddCustodianResponse: + """Add a user to a matter + + **Parameters:** + + * **matter_id**: `str` (required) - The matter ID. + * **user_id**: `str` (required) - The user ID of the user to add to the matter. + + **Returns**: An [`AddCustodianResponse`][addcustodianresponse-model] object with the membership for the given matter and user. + """ + return AddCustodianResponse.parse_response( + self._parent.session.post( + f"/v1/legal-hold/matters/{matter_id}/custodians", + json={"userId": user_id}, + ) + ) + + def get_matters_page( + self, + creator_user_id: str = None, + active: bool = None, + name: str = None, + page_num: int = None, + page_size: int = None, + ) -> MattersPage: + """Get a page of matters + + **Parameters:** + + * **creator_user_id**: `str` - Find legal hold matters that were created by the user with this unique identifier. + * **active**: `bool` - When true, return only active matters. When false, return inactive legal hold matters. Defaults to returning all matters. + * **name**: `str` - Find legal hold matters whose 'name' either equals or partially contains this value. + * **page_num**: `int` - The page number to request. + * **page_size**: `int` - The page size to request + + **Returns**: A [`MattersPage`][matterspage-model] object with the page of matters. + """ + return MattersPage.parse_response( + self._parent.session.get( + "/v1/legal-hold/matters", + params={ + "creatorUserId": creator_user_id, + "active": active, + "name": name, + "page": page_num, + "pageSize": page_size, + }, + ) + ) + + def iter_all_matters( + self, + creator_user_id: str = None, + active: bool = None, + name: str = None, + page_size: int = None, + ) -> Iterator[Matter]: + """Get all matters + + **Parameters:** + + * **creator_user_id**: `str` - Find legal hold matters that were created by the user with this unique identifier. + * **active**: `bool` - When true, return only active matters. When false, return inactive legal hold matters. Defaults to returning all matters. + * **name**: `str` - Find legal hold matters whose 'name' either equals or partially contains this value. + * **page_num**: `int` - The page number to request. + * **page_size**: `int` - The page size to request + + **Returns**: A generator object that yields [`Matter`][matter-model] objects with the details of each matter. + """ + page_size = page_size or self._parent.settings.page_size + for page_num in count(1): + page = self.get_matters_page( + creator_user_id=creator_user_id, + active=active, + name=name, + page_num=page_num, + page_size=page_size, + ) + yield from page.matters + if len(page.matters) < page_size: + break + + def create_matter( + self, policy_id: str, name: str, description: str = None, notes: str = None + ) -> Matter: + """Create a matter. + + **Parameters:** + + * **policy_id**: `str` (Required) - The policy ID to be used by the created matter. + * **name**: `str` (Required) - The name of the matter to be created. + * **description**: `str` - The description of the matter to be created. + * **notes**: `str` - The notes for the matter to be created. + + **Returns**: A [`Matter`][matter-model] object with details of the created matter. + """ + return Matter.parse_response( + self._parent.session.post( + "/v1/legal-hold/matters", + json={ + "policyId": policy_id, + "name": name, + "description": description, + "notes": notes, + }, + ) + ) + + def deactivate_matter(self, matter_id: str) -> Response: + """ + Deactivate a matter. + + **Parameters:** + + * **matter_id**: `str` (required) - The unique ID for the matter. + + **Returns**: A `requests.Response` indicating success. + """ + return self._parent.session.post( + f"/v1/legal-hold/matters/{matter_id}/deactivate" + ) + + def remove_custodian(self, matter_id: str, user_id: str) -> Response: + """ + Remove a custodian from a matter. + + **Parameters:** + + * **matter_id**: `str` (required) - The unique ID for the matter. + * **user_id**: `str` (required) - The unique ID of the user. + + **Returns**: A `requests.Response` indicating success. + """ + return self._parent.session.post( + f"/v1/legal-hold/matters/{matter_id}/custodians/remove", + json={"userId": user_id}, + ) + + def get_matter(self, matter_id) -> Matter: + """Get a matter + + **Parameters:** + + * **matter_id**: `str` (required) - The ID of the matter. + + **Returns**: A [`Matter`][matter-model] object with matter details. + """ + return Matter.parse_response( + self._parent.session.get(f"/v1/legal-hold/matters/{matter_id}") + ) + + def reactivate_matter(self, matter_id: str) -> Response: + """ + Reactivate a matter. + + **Parameters:** + + * **matter_id**: `str` (required) - The unique ID for the matter. + + **Returns**: A `requests.Response` indicating success. + """ + return self._parent.session.post( + f"/v1/legal-hold/matters/{matter_id}/reactivate" + ) + + def get_policies_page( + self, page_num: int = None, page_size: int = None + ) -> LegalHoldPolicyPage: + """Get a page of policies + + **Parameters:** + + * **page_num**: `int` - The page number to request. + * **page_size**: `int` - The page size to request + + **Returns**: A [`LegalHoldPolicyPage`][legalholdpolicypage-model] object with the page of policies. + """ + return LegalHoldPolicyPage.parse_response( + self._parent.session.get( + "/v1/legal-hold/policies", + params={"page": page_num, "pageSize": page_size}, + ) + ) + + def iter_all_policies(self, page_size: int = None) -> Iterator[LegalHoldPolicy]: + """Iterate through all policies + + **Parameters:** + + * **page_size**: `int` - The page size to request + + **Returns**: A generator that yields [`LegalHoldPolicy`][legalholdpolicy-model] objects. + """ + page_size = page_size or self._parent.settings.page_size + for page_num in count(1): + page = self.get_policies_page(page_num=page_num, page_size=page_size) + yield from page.policies + if len(page.policies) < page_size: + break + + def create_policy(self, name: str) -> LegalHoldPolicy: + """Create a legal hold policy. + + **Parameters:** + + * **name**: `str` (Required) - The name of the policy to create. + + **Returns**: A [`LegalHoldPolicy`][legalholdpolicy-model] object with details of the created policy. + """ + return LegalHoldPolicy.parse_response( + self._parent.session.post("/v1/legal-hold/policies", json={"name": name}) + ) + + def get_policy(self, policy_id: str) -> LegalHoldPolicy: + """Get details of a legal hold policy. + + **Parameters:** + + * **policy_id**: `str` (Required) - The unique ID of the policy. + + **Returns**: A [`LegalHoldPolicy`][legalholdpolicy-model] object with details of the policy. + """ + return LegalHoldPolicy.parse_response( + self._parent.session.get(f"/v1/legal-hold/policies/{policy_id}") + ) diff --git a/src/_incydr_sdk/legal_hold/models.py b/src/_incydr_sdk/legal_hold/models.py new file mode 100644 index 0000000..cb3328d --- /dev/null +++ b/src/_incydr_sdk/legal_hold/models.py @@ -0,0 +1,183 @@ +from datetime import datetime +from typing import List +from typing import Optional + +from pydantic import Field + +from _incydr_sdk.core.models import ResponseModel + + +class CreatorUser(ResponseModel): + user_id: Optional[str] = Field( + None, + alias="userId", + ) + username: Optional[str] = Field( + None, + ) + + +class CreatorPrincipal(ResponseModel): + type: Optional[str] = Field( + None, + ) + principal_id: Optional[str] = Field( + None, + alias="principalId", + ) + display_name: Optional[str] = Field( + None, + alias="displayName", + ) + + +class Matter(ResponseModel): + """A model representing a legal hold matter. + + **Fields**: + + * **matter_id**: `str` + * **name**: `str` + * **description**: `str` + * **notes**: `str` + * **active**: `bool` + * **creation_date**: `datetime` + * **policy_id**: `str` + * **creator**: `CreatorUser` + * **creator_principal**: `CreatorPrincipal` + """ + + matter_id: Optional[str] = Field( + None, + alias="matterId", + ) + name: Optional[str] = Field( + None, + ) + description: Optional[str] = Field( + None, + ) + notes: Optional[str] = Field( + None, + ) + active: Optional[bool] = Field( + None, + ) + creation_date: Optional[datetime] = Field( + None, + alias="creationDate", + ) + policy_id: Optional[str] = Field( + None, + alias="policyId", + ) + creator: Optional[CreatorUser] = Field( + None, + ) + creator_principal: Optional[CreatorPrincipal] = Field( + None, + alias="creatorPrincipal", + ) + + +class MattersPage(ResponseModel): + matters: Optional[List[Matter]] = Field( + None, + ) + + +class CustodianMatter(ResponseModel): + membership_active: Optional[bool] = Field( + None, + alias="membershipActive", + ) + membership_creation_date: Optional[datetime] = Field( + None, + alias="membershipCreationDate", + ) + matter_id: Optional[str] = Field( + None, + alias="matterId", + ) + name: Optional[str] = Field( + None, + ) + + +class CustodianMattersPage(ResponseModel): + matters: Optional[List[CustodianMatter]] = Field( + None, + ) + + +class Custodian(ResponseModel): + membership_active: Optional[bool] = Field( + None, + alias="membershipActive", + ) + membership_creation_date: Optional[datetime] = Field( + None, + alias="membershipCreationDate", + ) + user_id: Optional[str] = Field( + None, + alias="userId", + ) + name: Optional[str] = Field( + None, + ) + email: Optional[str] = Field( + None, + ) + + +class CustodiansPage(ResponseModel): + custodians: Optional[List[Custodian]] = Field( + None, + ) + + +class AddCustodianResponseMatter(ResponseModel): + matter_id: Optional[str] = Field(None, alias="matterId") + name: Optional[str] = Field( + None, + ) + + +class AddCustodianResponseCustodian(ResponseModel): + user_id: Optional[str] = Field(None, alias="userId") + username: Optional[str] = Field( + None, + ) + email: Optional[str] = Field( + None, + ) + + +class AddCustodianResponse(ResponseModel): + membership_active: Optional[bool] = Field(None, alias="membershipActive") + membership_creation_date: Optional[datetime] = Field( + None, alias="membershipCreationDate" + ) + matter: Optional[AddCustodianResponseMatter] = Field( + None, + ) + custodian: Optional[AddCustodianResponseCustodian] = Field( + None, + ) + + +class LegalHoldPolicy(ResponseModel): + policy_id: Optional[str] = Field(None, alias="policyId") + name: Optional[str] = Field(None) + creation_date: Optional[datetime] = Field(None, alias="creationDate") + modification_date: Optional[datetime] = Field(None, alias="modificationDate") + creator_user: Optional[CreatorUser] = Field(None, alias="creatorUser") + creator_principal: Optional[CreatorPrincipal] = Field( + None, + alias="creatorPrincipal", + ) + + +class LegalHoldPolicyPage(ResponseModel): + policies: Optional[List[LegalHoldPolicy]] diff --git a/src/_incydr_sdk/orgs/models.py b/src/_incydr_sdk/orgs/models.py index f20b615..fdbeb18 100644 --- a/src/_incydr_sdk/orgs/models.py +++ b/src/_incydr_sdk/orgs/models.py @@ -24,19 +24,19 @@ class Org(ResponseModel): **Fields**: - * **org_guid: `str` - The globally unique ID of this org. - * **org_name: `str` - The name of this org. - * **org_ext_ref: `str` - Optional external reference information, such as a serial number, asset tag, employee ID, or help desk issue ID. - * **notes: `str` - The notes for this org. Intended for optional additional descriptive information. - * **parent_org_guid: `str` - The globally unique ID of the parent org. - * **active: `bool` - Whether or not the org is currently active. - * **creation_date: `datetime` - Date and time this org was created. - * **modification_date: `datetime` - Date and time this org was last modified. - * **deactivation_date: `datetime` - Date and time this org was deactivated. Blank if org is active. - * **registration_key: `str` - The registration key for the org. - * **user_count: `int` - The count of users within this org. - * **computer_count: `int` - The count of computers within this org. - * **org_count: `int` - The count of child orgs for this org. + * **org_guid**: `str` - The globally unique ID of this org. + * **org_name**: `str` - The name of this org. + * **org_ext_ref**: `str` - Optional external reference information, such as a serial number, asset tag, employee ID, or help desk issue ID. + * **notes**: `str` - The notes for this org. Intended for optional additional descriptive information. + * **parent_org_guid**: `str` - The globally unique ID of the parent org. + * **active**: `bool` - Whether or not the org is currently active. + * **creation_date**: `datetime` - Date and time this org was created. + * **modification_date**: `datetime` - Date and time this org was last modified. + * **deactivation_date**: `datetime` - Date and time this org was deactivated. Blank if org is active. + * **registration_key**: `str` - The registration key for the org. + * **user_count**: `int` - The count of users within this org. + * **computer_count**: `int` - The count of computers within this org. + * **org_count**: `int` - The count of child orgs for this org. """ org_guid: Optional[str] = Field( diff --git a/tests/test_legal_hold.py b/tests/test_legal_hold.py new file mode 100644 index 0000000..3d9a2b6 --- /dev/null +++ b/tests/test_legal_hold.py @@ -0,0 +1,410 @@ +import pytest +from pytest_httpserver import HTTPServer + +from _incydr_cli.main import incydr +from _incydr_sdk.legal_hold.models import CustodianMattersPage +from _incydr_sdk.legal_hold.models import CustodiansPage +from _incydr_sdk.legal_hold.models import LegalHoldPolicy +from _incydr_sdk.legal_hold.models import LegalHoldPolicyPage +from _incydr_sdk.legal_hold.models import Matter +from _incydr_sdk.legal_hold.models import MattersPage +from incydr import Client + + +TEST_MATTER_ID = "matter id" +TEST_USER_ID = "user id" +TEST_POLICY_ID = "policy id" + +TEST_MATTER = { + "matter_id": "1221742823190440525", + "name": "sdk test matter", + "description": "", + "notes": None, + "active": True, + "creation_date": "2025-06-03T15:04:29.965+00:00", + "policy_id": "1221742699257145933", + "creator": None, + "creator_principal": { + "type": "API_KEY", + "principal_id": "api key", + "display_name": "cecilia saved", + }, +} + +TEST_MATTERS_PAGE = {"matters": [TEST_MATTER]} + +TEST_CUSTODIAN_MATTERS_PAGE = { + "matters": [ + { + "membershipActive": True, + "membershipCreationDate": "2025-06-03T15:10:37.713000Z", + "matterId": "matter one", + "name": "sdk test matter", + }, + { + "membershipActive": True, + "membershipCreationDate": "2024-10-11T14:29:33.829000Z", + "matterId": "matter two", + "name": "sdk test matter 2", + }, + ] +} + +TEST_CUSTODIANS_PAGE = { + "custodians": [ + { + "membershipActive": True, + "membershipCreationDate": "2025-06-03T15:10:37.713000Z", + "userId": "id one", + "name": None, + "email": "email@code42.com", + "username": "email@code42.com", + }, + { + "membershipActive": True, + "membershipCreationDate": "2025-06-03T15:10:37.713000Z", + "userId": "id two", + "name": None, + "email": "email@code42.com", + "username": "email@code42.com", + }, + ] +} + +TEST_ADD_CUSTODIAN_RESPONSE = { + "membershipActive": True, + "membershipCreationDate": "2025-06-03T15:10:37.713000Z", + "matter": {"matterId": "1221742823190440525", "name": "sdk test matter"}, + "custodian": { + "userId": "938960273869958201", + "username": "cecilia.stevens+test@code42.com", + "email": "cecilia.stevens+test@code42.com", + }, +} + +TEST_POLICY = { + "policyId": "policy id", + "name": "sdk test", + "creationDate": "2025-06-03T15:03:16.093000Z", + "modificationDate": "2025-06-03T15:03:16.093000Z", + "creatorUser": None, + "creatorPrincipal": { + "type": "API_KEY", + "principalId": "api key", + "displayName": "cecilia saved", + }, +} + +TEST_POLICIES_PAGE = {"policies": [TEST_POLICY]} + + +@pytest.fixture +def mock_memberships_page(httpserver_auth: HTTPServer): + httpserver_auth.expect_request( + f"/v1/legal-hold/custodians/{TEST_USER_ID}", method="GET" + ).respond_with_json(response_json=TEST_CUSTODIAN_MATTERS_PAGE, status=200) + return httpserver_auth + + +@pytest.fixture +def mock_custodians_page(httpserver_auth: HTTPServer): + httpserver_auth.expect_request( + f"/v1/legal-hold/matters/{TEST_MATTER_ID}/custodians", method="GET" + ).respond_with_json(response_json=TEST_CUSTODIANS_PAGE, status=200) + return httpserver_auth + + +@pytest.fixture +def mock_add_custodian(httpserver_auth: HTTPServer): + httpserver_auth.expect_request( + f"/v1/legal-hold/matters/{TEST_MATTER_ID}/custodians", + method="POST", + json={"userId": TEST_USER_ID}, + ).respond_with_json(response_json=TEST_ADD_CUSTODIAN_RESPONSE, status=200) + return httpserver_auth + + +@pytest.fixture +def mock_remove_custodian(httpserver_auth: HTTPServer): + httpserver_auth.expect_request( + f"/v1/legal-hold/matters/{TEST_MATTER_ID}/custodians/remove", + method="POST", + json={"userId": TEST_USER_ID}, + ).respond_with_data(response_data="", status=200) + return httpserver_auth + + +@pytest.fixture +def mock_matters_page(httpserver_auth: HTTPServer): + httpserver_auth.expect_request( + "/v1/legal-hold/matters", method="GET" + ).respond_with_json(response_json=TEST_MATTERS_PAGE, status=200) + return httpserver_auth + + +@pytest.fixture +def mock_create_matter(httpserver_auth: HTTPServer): + httpserver_auth.expect_request( + "/v1/legal-hold/matters", + method="POST", + json={ + "policyId": TEST_MATTER["policy_id"], + "name": TEST_MATTER["name"], + "description": None, + "notes": None, + }, + ).respond_with_json(response_json=TEST_MATTER, status=200) + return httpserver_auth + + +@pytest.fixture +def mock_deactivate_matter(httpserver_auth: HTTPServer): + httpserver_auth.expect_request( + f"/v1/legal-hold/matters/{TEST_MATTER_ID}/deactivate", method="POST" + ).respond_with_data(response_data="", status=200) + return httpserver_auth + + +@pytest.fixture +def mock_reactivate_matter(httpserver_auth: HTTPServer): + httpserver_auth.expect_request( + f"/v1/legal-hold/matters/{TEST_MATTER_ID}/reactivate", method="POST" + ).respond_with_data(response_data="", status=200) + return httpserver_auth + + +@pytest.fixture +def mock_get_matter(httpserver_auth: HTTPServer): + httpserver_auth.expect_request( + f"/v1/legal-hold/matters/{TEST_MATTER_ID}", method="GET" + ).respond_with_json(response_json=TEST_MATTER, status=200) + return httpserver_auth + + +@pytest.fixture +def mock_policies_page(httpserver_auth: HTTPServer): + httpserver_auth.expect_request( + "/v1/legal-hold/policies", method="GET" + ).respond_with_json(response_json=TEST_POLICIES_PAGE, status=200) + return httpserver_auth + + +@pytest.fixture +def mock_get_policy(httpserver_auth: HTTPServer): + httpserver_auth.expect_request( + f"/v1/legal-hold/policies/{TEST_POLICY_ID}", method="GET" + ).respond_with_json(response_json=TEST_POLICY, status=200) + return httpserver_auth + + +def test_get_memberships_page_for_user_makes_correct_call(mock_memberships_page): + c = Client() + response = c.legal_hold.v1.get_memberships_page_for_user(user_id=TEST_USER_ID) + assert isinstance(response, CustodianMattersPage) + mock_memberships_page.check() + + +def test_iter_all_memberships_for_user_iterates_memberships(mock_memberships_page): + c = Client() + response = list(c.legal_hold.v1.iter_all_memberships_for_user(user_id=TEST_USER_ID)) + assert len(response) == 2 + mock_memberships_page.check() + + +def test_get_custodians_page_makes_correct_call(mock_custodians_page): + c = Client() + response = c.legal_hold.v1.get_custodians_page(matter_id=TEST_MATTER_ID) + assert isinstance(response, CustodiansPage) + mock_custodians_page.check() + + +def test_iter_all_custodians_iterates_custodians(mock_custodians_page): + c = Client() + response = list(c.legal_hold.v1.iter_all_custodians(matter_id=TEST_MATTER_ID)) + assert len(response) == 2 + mock_custodians_page.check() + + +def test_add_custodian_adds_custodian(mock_add_custodian): + c = Client() + c.legal_hold.v1.add_custodian(matter_id=TEST_MATTER_ID, user_id=TEST_USER_ID) + mock_add_custodian.check() + + +def test_get_matters_page_makes_correct_call(mock_matters_page): + c = Client() + response = c.legal_hold.v1.get_matters_page() + assert isinstance(response, MattersPage) + mock_matters_page.check() + + +def test_iter_all_matters_iterates_matters(mock_matters_page): + c = Client() + response = list(c.legal_hold.v1.iter_all_matters()) + assert len(response) == 1 + mock_matters_page.check() + + +def test_create_matter_makes_correct_call(mock_create_matter): + c = Client() + response = c.legal_hold.v1.create_matter( + name=TEST_MATTER["name"], policy_id=TEST_MATTER["policy_id"] + ) + assert isinstance(response, Matter) + mock_create_matter.check() + + +def test_deactivate_matter_makes_correct_call(mock_deactivate_matter): + c = Client() + c.legal_hold.v1.deactivate_matter(matter_id=TEST_MATTER_ID) + mock_deactivate_matter.check() + + +def test_reactivate_matter_makes_correct_call(mock_reactivate_matter): + c = Client() + c.legal_hold.v1.reactivate_matter(matter_id=TEST_MATTER_ID) + mock_reactivate_matter.check() + + +def test_remove_custodian_makes_correct_call(mock_remove_custodian): + c = Client() + c.legal_hold.v1.remove_custodian(matter_id=TEST_MATTER_ID, user_id=TEST_USER_ID) + mock_remove_custodian.check() + + +def test_get_matter_makes_correct_call(mock_get_matter): + c = Client() + result = c.legal_hold.v1.get_matter(matter_id=TEST_MATTER_ID) + assert isinstance(result, Matter) + mock_get_matter.check() + + +def test_get_policies_page_makes_correct_call(mock_policies_page): + c = Client() + result = c.legal_hold.v1.get_policies_page() + assert isinstance(result, LegalHoldPolicyPage) + mock_policies_page.check() + + +def test_iter_all_policies_iterates_policies(mock_policies_page): + c = Client() + result = list(c.legal_hold.v1.iter_all_policies()) + assert len(result) == 1 + mock_policies_page.check() + + +def test_get_policy_makes_correct_call(mock_get_policy): + c = Client() + result = c.legal_hold.v1.get_policy(policy_id=TEST_POLICY_ID) + assert isinstance(result, LegalHoldPolicy) + mock_get_policy.check() + + +# ************************************************ CLI ************************************************ + + +def test_cli_list_matters_for_user_lists_memberships(runner, mock_memberships_page): + result = runner.invoke( + incydr, ["legal-hold", "list-matters-for-user", TEST_USER_ID] + ) + assert "sdk test matter" in result.output + assert "sdk test matter 2" in result.output + assert result.exit_code == 0 + mock_memberships_page.check() + + +def test_cli_list_custodians_lists_custodians(runner, mock_custodians_page): + result = runner.invoke(incydr, ["legal-hold", "list-custodians", TEST_MATTER_ID]) + assert "id one" in result.output + assert "id two" in result.output + assert result.exit_code == 0 + mock_custodians_page.check() + + +def test_cli_add_custodian_adds_custodian(runner, mock_add_custodian): + result = runner.invoke( + incydr, + [ + "legal-hold", + "add-custodian", + "--user-id", + TEST_USER_ID, + "--matter-id", + TEST_MATTER_ID, + ], + ) + assert result.exit_code == 0 + mock_add_custodian.check() + + +def test_cli_remove_custodian_removes_custodian(runner, mock_remove_custodian): + result = runner.invoke( + incydr, + [ + "legal-hold", + "remove-custodian", + "--user-id", + TEST_USER_ID, + "--matter-id", + TEST_MATTER_ID, + ], + ) + assert result.exit_code == 0 + mock_remove_custodian.check() + + +def test_cli_list_matters_lists_matters(runner, mock_matters_page): + result = runner.invoke(incydr, ["legal-hold", "list-matters"]) + assert "sdk test matter" in result.output + assert result.exit_code == 0 + mock_matters_page.check() + + +def test_cli_create_matter_creates_matter(runner, mock_create_matter): + result = runner.invoke( + incydr, + [ + "legal-hold", + "create-matter", + "--policy-id", + TEST_MATTER["policy_id"], + "--name", + TEST_MATTER["name"], + ], + ) + assert "sdk test matter" in result.output + assert result.exit_code == 0 + mock_create_matter.check() + + +def test_cli_deactivate_matter_deactivates_matter(runner, mock_deactivate_matter): + result = runner.invoke(incydr, ["legal-hold", "deactivate-matter", TEST_MATTER_ID]) + assert result.exit_code == 0 + mock_deactivate_matter.check() + + +def test_cli_reactivate_matter_reactivates_matter(runner, mock_reactivate_matter): + result = runner.invoke(incydr, ["legal-hold", "reactivate-matter", TEST_MATTER_ID]) + assert result.exit_code == 0 + mock_reactivate_matter.check() + + +def test_cli_show_matter_shows_matter(runner, mock_get_matter): + result = runner.invoke(incydr, ["legal-hold", "show-matter", TEST_MATTER_ID]) + assert "sdk test matter" in result.output + assert result.exit_code == 0 + mock_get_matter.check() + + +def test_cli_list_policies_lists_policies(runner, mock_policies_page): + result = runner.invoke(incydr, ["legal-hold", "list-policies"]) + assert "policy id" in result.output + assert result.exit_code == 0 + mock_policies_page.check() + + +def test_cli_show_policy_shows_policy(runner, mock_get_policy): + result = runner.invoke(incydr, ["legal-hold", "show-policy", TEST_POLICY_ID]) + assert "policy id" in result.output + assert result.exit_code == 0 + mock_get_policy.check() From 27a6f61c6f32594d3cc6057eb981845f3519f428 Mon Sep 17 00:00:00 2001 From: Cecilia Stevens <63068179+ceciliastevens@users.noreply.github.com> Date: Tue, 3 Jun 2025 16:48:56 -0400 Subject: [PATCH 3/4] add upgrade instructions to documentation --- docs/cli/index.md | 6 ++++++ docs/sdk/index.md | 7 +++++++ 2 files changed, 13 insertions(+) diff --git a/docs/cli/index.md b/docs/cli/index.md index f13af2a..bdf936c 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -13,6 +13,12 @@ Install the CLI extension to the Incydr SDK with pip. Use the following command $ pip install 'incydr[cli]' ``` +To upgrade an existing installation, use the `--upgrade` flag: + +```bash +$ pip install 'incydr[cli]' --upgrade +``` + See [Getting Started](getting_started.md) for more further details on setting up your Incydr CLI. ## Commands diff --git a/docs/sdk/index.md b/docs/sdk/index.md index c8c65a3..44a7f92 100644 --- a/docs/sdk/index.md +++ b/docs/sdk/index.md @@ -15,6 +15,13 @@ Install using pip: $ pip install incydr ``` +To upgrade an existing installation, use the `--upgrade` flag: + +```bash +$ pip install incydr --upgrade +``` + + Import the `incydr.Client` initialize with your Incydr API Client: ```python From 983a90bfb2430cb94e2290c07221dbc875c00c82 Mon Sep 17 00:00:00 2001 From: Cecilia Stevens <63068179+ceciliastevens@users.noreply.github.com> Date: Wed, 4 Jun 2025 09:47:01 -0400 Subject: [PATCH 4/4] fix docstrings --- src/_incydr_cli/cmds/legal_hold.py | 2 +- src/_incydr_sdk/orgs/client.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/_incydr_cli/cmds/legal_hold.py b/src/_incydr_cli/cmds/legal_hold.py index addb29c..ca97eff 100644 --- a/src/_incydr_cli/cmds/legal_hold.py +++ b/src/_incydr_cli/cmds/legal_hold.py @@ -55,7 +55,7 @@ def list_matters_for_user(user_id: str, format_: TableFormat, columns: Optional[ @columns_option @logging_options def list_custodians(matter_id: str, format_: TableFormat, columns: Optional[str]): - """List the matter memberships for a specific user.""" + """List the custodians for a specific matter.""" client = Client() memberships_ = client.legal_hold.v1.iter_all_custodians(matter_id=matter_id) diff --git a/src/_incydr_sdk/orgs/client.py b/src/_incydr_sdk/orgs/client.py index 5790b9f..f803c04 100644 --- a/src/_incydr_sdk/orgs/client.py +++ b/src/_incydr_sdk/orgs/client.py @@ -111,7 +111,7 @@ def update( self, org_guid: str, org_name: str, org_ext_ref: str = None, notes: str = None ) -> Org: """ - Create an org. + Update an org. **Parameters:**