diff --git a/cognite/client/_api/iam.py b/cognite/client/_api/iam.py index 9fbb280be..8332f4f20 100644 --- a/cognite/client/_api/iam.py +++ b/cognite/client/_api/iam.py @@ -1,7 +1,7 @@ from __future__ import annotations import warnings -from collections.abc import Iterable, Sequence +from collections.abc import Iterable, Iterator, Sequence from itertools import groupby from operator import itemgetter from typing import TYPE_CHECKING, Any, Literal, TypeAlias, cast, overload @@ -36,11 +36,20 @@ from cognite.client.data_classes.iam import ( GroupWrite, SecurityCategoryWrite, + ServiceAccount, + ServiceAccountList, + ServiceAccountSecret, + ServiceAccountSecretList, + ServiceAccountUpdate, + ServiceAccountWrite, SessionStatus, SessionType, TokenInspection, ) +from cognite.client.utils._auxiliary import interpolate_and_url_encode +from cognite.client.utils._experimental import FeaturePreviewWarning from cognite.client.utils._identifier import IdentifierSequence +from cognite.client.utils.useful_types import SequenceNotStr if TYPE_CHECKING: from cognite.client import CogniteClient @@ -105,6 +114,7 @@ def __init__(self, config: ClientConfig, api_version: str | None, cognite_client self.user_profiles = UserProfilesAPI(config, api_version, cognite_client) # TokenAPI only uses base_url, so we pass `api_version=None`: self.token = TokenAPI(config, api_version=None, cognite_client=cognite_client) + self.service_accounts = ServiceAccountsAPI(config, api_version, cognite_client) @staticmethod def compare_capabilities( @@ -653,3 +663,386 @@ def list(self, status: SessionStatus | None = None, limit: int = DEFAULT_LIMIT_R """ filter = {"status": status.upper()} if status is not None else None return self._list(list_cls=SessionList, resource_cls=Session, method="GET", filter=filter, limit=limit) + + +class ServiceAccountsAPI(APIClient): + _RESOURCE_PATH = "/api/v1/orgs/{}/serviceaccounts" + + def __init__(self, config: ClientConfig, api_version: str | None, cognite_client: CogniteClient) -> None: + super().__init__(config, api_version, cognite_client) + self._warning = FeaturePreviewWarning( + api_maturity="alpha", sdk_maturity="alpha", feature_name="ServiceAccounts" + ) + self.secrets = ServiceAccountSecretsAPI(config, api_version, cognite_client, self._warning) + + @overload + def __call__( + self, + chunk_size: None = None, + limit: int | None = None, + ) -> Iterator[ServiceAccount]: ... + + @overload + def __call__( + self, + chunk_size: int, + limit: int | None = None, + ) -> Iterator[ServiceAccountList]: ... + + def __call__( + self, + chunk_size: int | None = None, + limit: int | None = None, + ) -> Iterator[ServiceAccount] | Iterator[ServiceAccountList]: + """Iterate over service accounts + + Fetches service accounts as they are iterated over, so you keep a limited number of service accounts in memory. + + Args: + chunk_size (int | None): Number of service accounts to return in each chunk. Defaults to yielding one service account a time. + limit (int | None): Maximum number of service accounts to return. Defaults to return all. + + Returns: + Iterator[ServiceAccount] | Iterator[ServiceAccountList]: yields ServiceAccount one by one if chunk_size is not specified, else ServiceAccountList objects. + """ + self._warning.warn() + + return self._list_generator( + list_cls=ServiceAccountList, + resource_cls=ServiceAccount, + method="GET", + chunk_size=chunk_size, + limit=limit, + headers={"cdf-version": "alpha"}, + ) + + def __iter__(self) -> Iterator[ServiceAccount]: + """Iterate over service accounts + + Fetches service accounts as they are iterated over, so you keep a + limited number of service accounts in memory. + + Returns: + Iterator[ServiceAccount]: yields service account one by one. + """ + return self() + + @overload + def create(self, org: str, item: ServiceAccountWrite) -> ServiceAccount: ... + + @overload + def create(self, org: str, item: Sequence[ServiceAccountWrite]) -> ServiceAccountList: ... + + def create( + self, + org: str, + item: ServiceAccountWrite | Sequence[ServiceAccountWrite], + ) -> ServiceAccount | ServiceAccountList: + """`Create service accounts `_ + + Create service accounts in an organization. + + #### Access control + Requires the caller to be an admin in the + target organization. + + Args: + org (str): ID of an organization + item (ServiceAccountWrite | Sequence[ServiceAccountWrite]): Service account or list of service accounts to create. + + Returns: + ServiceAccount | ServiceAccountList: The created service account or service accounts. + + Examples: + + + + """ + self._warning.warn() + + return self._create_multiple( + resource_path=interpolate_and_url_encode(self._RESOURCE_PATH, org), + list_cls=ServiceAccountList, + resource_cls=ServiceAccount, + items=item, + input_resource_cls=ServiceAccountWrite, + headers={"cdf-version": "alpha"}, + ) + + @overload + def update(self, org: str, item: ServiceAccountWrite | ServiceAccountUpdate) -> ServiceAccount: ... + + @overload + def update(self, org: str, item: Sequence[ServiceAccount | ServiceAccountUpdate]) -> ServiceAccountList: ... + + def update( + self, + org: str, + item: ServiceAccountWrite | ServiceAccountUpdate | Sequence[ServiceAccount | ServiceAccountUpdate], + mode: Literal["replace_ignore_null", "patch", "replace"] = "replace_ignore_null", + ) -> ServiceAccount | ServiceAccountList: + """`Update service accounts `_ + + Update service accounts in an organization. + + #### Access control + Requires the caller to be an admin in the + target organization. + + Args: + org (str): ID of an organization + item (ServiceAccountWrite | ServiceAccountUpdate | Sequence[ServiceAccount | ServiceAccountUpdate]): Service account or list of service accounts to update. + mode (Literal["replace_ignore_null", "patch", "replace"]): The update mode to use. Defaults to 'replace_ignore_null'. + + Returns: + ServiceAccount | ServiceAccountList: The updated service account or service accounts. + + Examples: + + + + """ + self._warning.warn() + + return self._update_multiple( + resource_path=interpolate_and_url_encode(self._RESOURCE_PATH, org), + list_cls=ServiceAccountList, + resource_cls=ServiceAccount, + update_cls=ServiceAccountUpdate, + items=item, + mode=mode, + headers={"cdf-version": "alpha"}, + ) + + def delete( + self, org: str, id: int | Sequence[int] | None = None, external_id: str | SequenceNotStr[str] | None = None + ) -> None: + """`Delete service accounts `_ + + Delete service accounts in an organization. All secrets associated with the service accounts will be deleted + as well. + + #### Access control + Requires the caller to be an admin in the target organization. + + Args: + org (str): ID of an organization + id (int | Sequence[int] | None): ID or list of IDs of service accounts to delete. + external_id (str | SequenceNotStr[str] | None): External ID or list of external IDs of service accounts to delete. + + Examples: + + + + """ + self._warning.warn() + + self._delete_multiple( + resource_path=interpolate_and_url_encode(self._RESOURCE_PATH, org), + identifiers=IdentifierSequence.load(ids=id, external_ids=external_id), + wrap_ids=False, + headers={"cdf-version": "alpha"}, + ) + + def list(self, org: str, limit: int | None = DEFAULT_LIMIT_READ) -> ServiceAccount | ServiceAccountList: + """`List service accounts `_ + + List service accounts in an organization. + + #### Access control + Requires the caller to be logged into the target + organization. + + Args: + org (str): ID of an organization + limit (int | None): Max number of service accounts to return. Defaults to 25. Set to -1, float("inf") or None to return all items. + + Returns: + ServiceAccount | ServiceAccountList: A service account or list of service accounts. + + Examples: + + + + """ + self._warning.warn() + + return self._list( + resource_path=interpolate_and_url_encode(self._RESOURCE_PATH, org), + list_cls=ServiceAccountList, + resource_cls=ServiceAccount, + method="GET", + limit=limit, + headers={"cdf-version": "alpha"}, + ) + + +class ServiceAccountSecretsAPI(APIClient): + _RESOURCE_PATH = "/api/v1/orgs/{}/serviceaccounts/{}/secrets" + + def __init__( + self, + config: ClientConfig, + api_version: str | None, + cognite_client: CogniteClient, + warning: FeaturePreviewWarning, + ) -> None: + super().__init__(config, api_version, cognite_client) + self._warning = warning + + @overload + def __call__( + self, + chunk_size: None = None, + limit: int | None = None, + ) -> Iterator[ServiceAccountSecret]: ... + + @overload + def __call__( + self, + chunk_size: int, + limit: int | None = None, + ) -> Iterator[ServiceAccountSecretList]: ... + + def __call__( + self, + chunk_size: int | None = None, + limit: int | None = None, + ) -> Iterator[ServiceAccountSecret] | Iterator[ServiceAccountSecretList]: + """Iterate over service account secrets + + Fetches service account secret as they are iterated over, so you keep a limited number of service account secrets in memory. + + Args: + chunk_size (int | None): Number of service account secretss to return in each chunk. Defaults to yielding one service account secret a time. + limit (int | None): Maximum number of service account secrets to return. Defaults to return all. + + Returns: + Iterator[ServiceAccountSecret] | Iterator[ServiceAccountSecretList]: yields ServiceAccountSecret one by one if chunk_size is not specified, else ServiceAccountSecretList objects. + """ + self._warning.warn() + + return self._list_generator( + list_cls=ServiceAccountSecretList, + resource_cls=ServiceAccountSecret, + method="GET", + chunk_size=chunk_size, + limit=limit, + headers={"cdf-version": "alpha"}, + ) + + def __iter__(self) -> Iterator[ServiceAccountSecret]: + """Iterate over service account secretss + + Fetches service account secrets as they are iterated over, so you keep a + limited number of service account secrets in memory. + + Returns: + Iterator[ServiceAccountSecret]: yields service account secret one by one. + """ + return self() + + @overload + def create(self, org: str, client_id: str, item: ServiceAccountWrite) -> ServiceAccountSecret: ... + + @overload + def create(self, org: str, client_id: str, item: Sequence[ServiceAccountWrite]) -> ServiceAccountSecretList: ... + + def create( + self, org: str, client_id: str, item: ServiceAccountWrite | Sequence[ServiceAccountWrite] + ) -> ServiceAccountSecret | ServiceAccountSecretList: + """`Create a service account secret `_ + + Create a secret for a service account. + + This is the only time when the client secret will be shown. Make sure to store it securely. + + #### Access control + Requires the caller to be an admin in the target organization. + + Args: + org (str): ID of an organization + client_id (str): None + item (ServiceAccountWrite | Sequence[ServiceAccountWrite]): Service account or list of service accounts to create. + + Returns: + ServiceAccountSecret | ServiceAccountSecretList: The created service account secret or service account secrets. + + Examples: + + + + """ + self._warning.warn() + + return self._create_multiple( + resource_path=interpolate_and_url_encode(self._RESOURCE_PATH, org, client_id), + list_cls=ServiceAccountSecretList, + resource_cls=ServiceAccountSecret, + items=item, + input_resource_cls=ServiceAccountWrite, + headers={"cdf-version": "alpha"}, + ) + + def delete(self, org: str, client_id: str, id: int | Sequence[int]) -> None: + """`Delete service account secrets `_ + + Delete secrets for a service account. + + #### Access control + Requires the caller to be an admin in the target + organization. + + Args: + org (str): ID of an organization + client_id (str): None + id (int | Sequence[int]): ID or list of IDs of service account secrets to delete. + + Examples: + + + + """ + self._warning.warn() + + self._delete_multiple( + resource_path=interpolate_and_url_encode(self._RESOURCE_PATH, org, client_id), + identifiers=IdentifierSequence.load(ids=id), + wrap_ids=False, + headers={"cdf-version": "alpha"}, + ) + + def list( + self, + org: str, + client_id: str, + ) -> ServiceAccountSecretList: + """`List service account secrets `_ + + List secrets for a service account. + + #### Access control + Requires the caller to be an admin in the target + organization. + + Args: + org (str): ID of an organization + client_id (str): Unique identifier of a service account + + Returns: + ServiceAccountSecretList: A list of service account secrets. + + Examples: + + + + """ + self._warning.warn() + + return self._list( + resource_path=interpolate_and_url_encode(self._RESOURCE_PATH, org, client_id), + list_cls=ServiceAccountSecretList, + resource_cls=ServiceAccountSecret, + method="GET", + headers={"cdf-version": "alpha"}, + ) diff --git a/cognite/client/data_classes/iam.py b/cognite/client/data_classes/iam.py index b9694f5c1..7336ef3c1 100644 --- a/cognite/client/data_classes/iam.py +++ b/cognite/client/data_classes/iam.py @@ -2,17 +2,20 @@ from abc import ABC from collections.abc import Iterable -from typing import TYPE_CHECKING, Any, Literal, TypeAlias, cast +from typing import TYPE_CHECKING, Any, Literal, NoReturn, TypeAlias, cast from typing_extensions import Self from cognite.client.data_classes._base import ( + CognitePrimitiveUpdate, CogniteResource, CogniteResourceList, CogniteResponse, + CogniteUpdate, IdTransformerMixin, InternalIdTransformerMixin, NameTransformerMixin, + PropertySpec, WriteableCogniteResource, WriteableCogniteResourceList, ) @@ -489,3 +492,181 @@ def dump(self, camel_case: bool = True) -> dict[str, Any]: @classmethod def _load(cls, resource: dict, cognite_client: CogniteClient | None = None) -> ClientCredentials: return cls(client_id=resource["clientId"], client_secret=resource["clientSecret"]) + + +class ServiceAccountCore(WriteableCogniteResource["ServiceAccountWrite"], ABC): + def __init__(self, name: str | None = None, external_id: str | None = None, description: str | None = None) -> None: + self.name = name + self.external_id = external_id + self.description = description + + +class ServiceAccountWrite(ServiceAccountCore): + """A service account. + + This is the write/request format of the service account dto. + + Args: + name (str | None): Human-readable name of a service account + external_id (str | None): The external ID provided by the client. Must be unique for the resource type. + description (str | None): Longer description of a service account + + """ + + def __init__(self, name: str | None = None, external_id: str | None = None, description: str | None = None) -> None: + super().__init__( + name=name, + external_id=external_id, + description=description, + ) + + def as_write(self) -> ServiceAccountWrite: + return self + + +class ServiceAccount(ServiceAccountCore): + """A service account. + + This is the read/response format of the service account. + + Args: + id (str | None): Unique identifier of a service account + external_id (str | None): The external ID provided by the client. Must be unique for the resource type. + name (str | None): Human-readable name of a service account + description (str | None): Longer description of a service account + created_by (str | None): The ID of an organization user + created_time (int | None): The number of milliseconds since 00:00:00 Thursday, 1 January 1970, Coordinated Universal Time (UTC), minus leap seconds. + last_updated_time (int | None): The number of milliseconds since 00:00:00 Thursday, 1 January 1970, Coordinated Universal Time (UTC), minus leap seconds. + + """ + + def __init__( + self, + id: str | None = None, + external_id: str | None = None, + name: str | None = None, + description: str | None = None, + created_by: str | None = None, + created_time: int | None = None, + last_updated_time: int | None = None, + ) -> None: + super().__init__( + name=name, + external_id=external_id, + description=description, + ) + self.id = id + self.created_by = created_by + self.created_time = created_time + self.last_updated_time = last_updated_time + + def as_write(self) -> ServiceAccountWrite: + return ServiceAccountWrite( + external_id=self.external_id, + description=self.description, + name=self.name, + ) + + +class ServiceAccountUpdate(CogniteUpdate): + class _NullableStringServiceAccountUpdate(CognitePrimitiveUpdate): + def set(self, value: str | None) -> ServiceAccountUpdate: + return self._set(value) + + class _StringServiceAccountUpdate(CognitePrimitiveUpdate): + def set(self, value: str) -> ServiceAccountUpdate: + return self._set(value) + + @property + def name(self) -> _StringServiceAccountUpdate: + return ServiceAccountUpdate._StringServiceAccountUpdate(self, "name") + + @property + def description(self) -> _NullableStringServiceAccountUpdate: + return ServiceAccountUpdate._NullableStringServiceAccountUpdate(self, "description") + + @property + def external_id(self) -> _NullableStringServiceAccountUpdate: + return ServiceAccountUpdate._NullableStringServiceAccountUpdate(self, "externalId") + + @classmethod + def _get_update_properties(cls, item: CogniteResource | None = None) -> list[PropertySpec]: + return [ + PropertySpec("name", is_nullable=False), + PropertySpec("description", is_nullable=True), + PropertySpec("external_id", is_nullable=True), + ] + + +class ServiceAccountWriteList(CogniteResourceList[ServiceAccountWrite]): + _RESOURCE = ServiceAccountWrite + + +class ServiceAccountList(WriteableCogniteResourceList[ServiceAccountWrite, ServiceAccount]): + _RESOURCE = ServiceAccount + + def as_write(self) -> ServiceAccountWriteList: + return ServiceAccountWriteList([item.as_write() for item in self.data]) + + +class ServiceAccountSecretCore(WriteableCogniteResource["ServiceAccountSecretWrite"], ABC): ... + + +class ServiceAccountSecretWrite(ServiceAccountSecretCore): + """A service account secret. + + This is the write/request format of the service account secret dto. + + Args: + expires_in_seconds (int | None): The number of seconds until the secret expires. The maximum value is 180 days. + + """ + + def __init__(self, expires_in_seconds: int | None = None) -> None: + self.expires_in_seconds = expires_in_seconds + + def as_write(self) -> ServiceAccountSecretWrite: + return self + + +class ServiceAccountSecret(ServiceAccountSecretCore): + """A service account secret. + + This is the read/response format of the service account secret dto. + + Args: + id (int | None): Unique identifier of a service account secret + client_id (str | None): Unique identifier of a service account + expiration_time (int | None): The number of milliseconds since 00:00:00 Thursday, 1 January 1970, Coordinated Universal Time (UTC), minus leap seconds. + created_time (int | None): The number of milliseconds since 00:00:00 Thursday, 1 January 1970, Coordinated Universal Time (UTC), minus leap seconds. + last_token_issuance_time (str | None): The time when this secret was last used to issue a CDF access token. + + """ + + def __init__( + self, + id: int | None = None, + client_id: str | None = None, + expiration_time: int | None = None, + created_time: int | None = None, + last_token_issuance_time: str | None = None, + ) -> None: + self.id = id + self.client_id = client_id + self.expiration_time = expiration_time + self.created_time = created_time + self.last_token_issuance_time = last_token_issuance_time + + def as_write(self) -> NoReturn: + raise TypeError(f"{type(self).__name__} cannot be converted to a write object") + + +class ServiceAccountSecretWriteList(CogniteResourceList[ServiceAccountSecretWrite]): + _RESOURCE = ServiceAccountSecretWrite + + +class ServiceAccountSecretList(WriteableCogniteResourceList[ServiceAccountSecretWrite, ServiceAccountSecret]): + _RESOURCE = ServiceAccountSecret + + def as_write(self) -> NoReturn: + raise TypeError(f"{type(self).__name__} cannot be converted to a write object") diff --git a/cognite/client/testing.py b/cognite/client/testing.py index 2a7be50ab..0064d002d 100644 --- a/cognite/client/testing.py +++ b/cognite/client/testing.py @@ -35,7 +35,15 @@ from cognite.client._api.hosted_extractors.jobs import JobsAPI from cognite.client._api.hosted_extractors.mappings import MappingsAPI from cognite.client._api.hosted_extractors.sources import SourcesAPI -from cognite.client._api.iam import IAMAPI, GroupsAPI, SecurityCategoriesAPI, SessionsAPI, TokenAPI +from cognite.client._api.iam import ( + IAMAPI, + GroupsAPI, + SecurityCategoriesAPI, + ServiceAccountsAPI, + ServiceAccountSecretsAPI, + SessionsAPI, + TokenAPI, +) from cognite.client._api.labels import LabelsAPI from cognite.client._api.postgres_gateway import PostgresGatewaysAPI from cognite.client._api.postgres_gateway.tables import TablesAPI as PostgresTablesAPI @@ -129,6 +137,8 @@ def __init__(self, *args: Any, **kwargs: Any) -> None: self.iam = MagicMock(spec=IAMAPI) self.iam.groups = MagicMock(spec_set=GroupsAPI) self.iam.security_categories = MagicMock(spec_set=SecurityCategoriesAPI) + self.iam.service_accounts = MagicMock(spec=ServiceAccountsAPI) + self.iam.service_accounts.secrets = MagicMock(spec_set=ServiceAccountSecretsAPI) self.iam.sessions = MagicMock(spec_set=SessionsAPI) self.iam.user_profiles = MagicMock(spec_set=UserProfilesAPI) self.iam.token = MagicMock(spec_set=TokenAPI) diff --git a/tests/tests_integration/test_api/test_iam.py b/tests/tests_integration/test_api/test_iam.py index a992bc4c1..462d8cf48 100644 --- a/tests/tests_integration/test_api/test_iam.py +++ b/tests/tests_integration/test_api/test_iam.py @@ -7,6 +7,13 @@ from cognite.client import CogniteClient from cognite.client.data_classes import CreatedSession, Group, GroupList, SecurityCategory from cognite.client.data_classes.capabilities import EventsAcl, ProjectCapabilityList +from cognite.client.data_classes.iam import ( + ServiceAccount, + ServiceAccountSecret, + ServiceAccountSecretWrite, + ServiceAccountUpdate, + ServiceAccountWrite, +) from cognite.client.utils._text import random_string @@ -87,3 +94,41 @@ def test_create_retrieve_and_revoke(self, cognite_client: CogniteClient) -> None if created: revoked = cognite_client.iam.sessions.revoke(created.id) assert created.id == revoked.id + + +class TestServiceAccountsAPI: + def test_create_update_retrieve_delete(self, cognite_client: CogniteClient) -> None: + org = "pytest_org" + item = ServiceAccountWrite( + name="test_" + random_string(10), + external_id=f"test_{random_string(10)}", + description="Original description", + ) + + created: ServiceAccount | None = None + secret: ServiceAccountSecret | None = None + try: + created = cognite_client.iam.service_accounts.create(org, item) + assert created.as_write().dump() == item.dump() + + update = ServiceAccountUpdate(id=created.id).description.set("Updated description") + + updated = cognite_client.iam.service_accounts.update(org, update) + assert updated.description == "Updated description" + + retrieved = cognite_client.iam.service_accounts.retrieve(created.id) + assert retrieved.as_write().dump() == updated.as_write().dump() + + secret = cognite_client.iam.service_accounts.secrets.create( + org, created.id, ServiceAccountSecretWrite(expires_in_seconds=3600) + ) + assert secret.id is not None + + listed = cognite_client.iam.service_accounts.secrets.list(org, created.id) + assert len(listed) == 1 + assert listed[0].id == secret.id + finally: + if created: + if secret: + cognite_client.iam.service_accounts.secrets.delete(org, created.id, secret.id) + cognite_client.iam.service_accounts.delete(created.id)