diff --git a/.gitignore b/.gitignore index a23aef252..84f943b0b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ .idea .vscode +.cursor *.iml .env .env.test diff --git a/docs/assets/scss/variables/_variables.colors.scss b/docs/assets/scss/variables/_variables.colors.scss index 006a4683a..6aade7442 100755 --- a/docs/assets/scss/variables/_variables.colors.scss +++ b/docs/assets/scss/variables/_variables.colors.scss @@ -6,7 +6,7 @@ $color-deep-purple: #1C0D3F; $color-indigo: #1B127D; $color-cobalt-blue: #2637EF; -$color-shocking-pink: #ED26B7; +$color-shocking-pink: #CF119C; $color-violet: #8104CA; $color-jade-green: #A3FFB0; $color-emerald-green: #20CA8B; diff --git a/gooddata-pipelines/gooddata_pipelines/api/gooddata_api.py b/gooddata-pipelines/gooddata_pipelines/api/gooddata_api.py index 38c72476f..59c09490d 100644 --- a/gooddata-pipelines/gooddata_pipelines/api/gooddata_api.py +++ b/gooddata-pipelines/gooddata_pipelines/api/gooddata_api.py @@ -212,6 +212,11 @@ def get_all_dashboards(self, workspace_id: str) -> requests.Response: headers = {**self.headers, "X-GDC-VALIDATE-RELATIONS": "true"} return self._get(endpoint, headers=headers) + def get_profile(self) -> requests.Response: + """Returns organization and current user information.""" + endpoint = "/profile" + return self._get(endpoint) + def _get( self, endpoint: str, headers: dict[str, str] | None = None ) -> requests.Response: diff --git a/gooddata-pipelines/gooddata_pipelines/provisioning/entities/users/models/users.py b/gooddata-pipelines/gooddata_pipelines/provisioning/entities/users/models/users.py index a29943f77..6a5918b09 100644 --- a/gooddata-pipelines/gooddata_pipelines/provisioning/entities/users/models/users.py +++ b/gooddata-pipelines/gooddata_pipelines/provisioning/entities/users/models/users.py @@ -3,7 +3,16 @@ from typing import Any from gooddata_sdk.catalog.user.entity_model.user import CatalogUser -from pydantic import BaseModel +from pydantic import BaseModel, Field + + +class UserProfile(BaseModel): + """Minimal model of api/v1/profile response. + + Does not contain all fields from the response. + """ + + user_id: str = Field(alias="userId") class BaseUser(BaseModel): diff --git a/gooddata-pipelines/gooddata_pipelines/provisioning/entities/users/users.py b/gooddata-pipelines/gooddata_pipelines/provisioning/entities/users/users.py index e63afd5d7..864508b29 100644 --- a/gooddata-pipelines/gooddata_pipelines/provisioning/entities/users/users.py +++ b/gooddata-pipelines/gooddata_pipelines/provisioning/entities/users/users.py @@ -11,6 +11,7 @@ from gooddata_pipelines.provisioning.entities.users.models.users import ( UserFullLoad, UserIncrementalLoad, + UserProfile, ) from gooddata_pipelines.provisioning.provisioning import Provisioning from gooddata_pipelines.provisioning.utils.context_objects import UserContext @@ -30,6 +31,8 @@ class UserProvisioner(Provisioning[UserFullLoad, UserIncrementalLoad]): source_group_incremental: list[UserIncrementalLoad] source_group_full: list[UserFullLoad] + current_user_id: str + FULL_LOAD_TYPE: type[UserFullLoad] = UserFullLoad INCREMENTAL_LOAD_TYPE: type[UserIncrementalLoad] = UserIncrementalLoad @@ -37,6 +40,19 @@ def __init__(self, host: str, token: str) -> None: super().__init__(host, token) self.upstream_user_cache: dict[UserId, UserModel] = {} + def _get_current_user_id(self) -> str: + """Gets the current user ID.""" + + profile_response = self._api.get_profile() + + if not profile_response.ok: + raise Exception("Failed to get current user profile") + + profile_json = profile_response.json() + profile = UserProfile.model_validate(profile_json) + + return profile.user_id + def _try_get_user( self, user: UserModel, model: type[UserModel] ) -> UserModel | None: @@ -99,6 +115,14 @@ def _create_or_update_user( for its existence and create it if needed. """ + + if user.user_id == self.current_user_id: + self.logger.warning( + f"Skipping creation/update of current user: {user.user_id}. " + + "Current user should not be modified.", + ) + return + user_context = UserContext( user_id=user.user_id, user_groups=user.user_groups, @@ -118,6 +142,13 @@ def _create_or_update_user( def _delete_user(self, user_id: str) -> None: """Deletes user from the project.""" + if user_id == self.current_user_id: + self.logger.warning( + f"Skipping deletion of current user: {user_id}." + + " Current user should not be deleted.", + ) + return + try: self._api._sdk.catalog_user.get_user(user_id) except NotFoundException: @@ -135,6 +166,9 @@ def _manage_user(self, user: UserIncrementalLoad) -> None: def _provision_incremental_load(self) -> None: """Runs the incremental provisioning logic.""" + # Set the current user ID + self.current_user_id = self._get_current_user_id() + for user in self.source_group_incremental: # Attempt to process each user. On failure, log the error and continue try: @@ -146,6 +180,10 @@ def _provision_incremental_load(self) -> None: def _provision_full_load(self) -> None: """Runs the full load provisioning logic.""" + + # Set the current user ID + self.current_user_id = self._get_current_user_id() + # Get all upstream users catalog_upstream_users: list[CatalogUser] = self._api.list_users() diff --git a/gooddata-pipelines/tests/data/profiles.yaml b/gooddata-pipelines/tests/data/profiles.yaml index 63349e3e6..ffcc716cb 100644 --- a/gooddata-pipelines/tests/data/profiles.yaml +++ b/gooddata-pipelines/tests/data/profiles.yaml @@ -1,4 +1,7 @@ # (C) 2025 GoodData Corporation -mock_profile: - host: http://localhost:3000 - token: some_user_token +profiles: + mock_profile: + host: http://localhost:3000 + token: $MOCK_TOKEN +default_profile: mock_profile +access: {} diff --git a/gooddata-pipelines/tests/data/provisioning/entities/users/existing_upstream_users.json b/gooddata-pipelines/tests/data/provisioning/entities/users/existing_upstream_users.json index 1e2c4d463..e59de21b3 100644 --- a/gooddata-pipelines/tests/data/provisioning/entities/users/existing_upstream_users.json +++ b/gooddata-pipelines/tests/data/provisioning/entities/users/existing_upstream_users.json @@ -22,5 +22,13 @@ "email": "jack.cliff@example.com", "authentication_id": "auth_4", "user_groups": ["group_4", "group_5"] + }, + { + "user_id": "protected_user_id", + "firstname": "Protected", + "lastname": "User", + "email": "protected.user@example.com", + "authentication_id": "auth_protected", + "user_groups": ["group_protected"] } ] diff --git a/gooddata-pipelines/tests/data/provisioning/entities/users/profile_response_content.json b/gooddata-pipelines/tests/data/provisioning/entities/users/profile_response_content.json new file mode 100644 index 000000000..14b6e3fe9 --- /dev/null +++ b/gooddata-pipelines/tests/data/provisioning/entities/users/profile_response_content.json @@ -0,0 +1,54 @@ +{ + "entitlements": [ + { + "expiry": "2019-08-24", + "name": "CacheStrategy", + "value": "string" + } + ], + "features": { + "live": { + "context": { + "earlyAccess": "string", + "earlyAccessValues": ["string"] + }, + "configuration": { + "host": "string", + "key": "string" + } + } + }, + "links": { + "organization": "string", + "self": "string", + "user": "string" + }, + "name": "string", + "organizationId": "string", + "organizationName": "string", + "permissions": ["MANAGE"], + "telemetryConfig": { + "context": { + "deploymentId": "string", + "organizationHash": "string", + "userHash": "string" + }, + "services": { + "amplitude": { + "aiProjectApiKey": "string", + "endpoint": "string", + "gdCommonApiKey": "string", + "reportingEndpoint": "string" + }, + "matomo": { + "host": "string", + "reportingEndpoint": "string", + "siteId": 0 + }, + "openTelemetry": { + "host": "string" + } + } + }, + "userId": "protected_user_id" +} diff --git a/gooddata-pipelines/tests/data/provisioning/entities/users/users_input_full_load_modifies_protected_user.json b/gooddata-pipelines/tests/data/provisioning/entities/users/users_input_full_load_modifies_protected_user.json new file mode 100644 index 000000000..50f0847d8 --- /dev/null +++ b/gooddata-pipelines/tests/data/provisioning/entities/users/users_input_full_load_modifies_protected_user.json @@ -0,0 +1,34 @@ +[ + { + "user_id": "user_1", + "firstname": "John", + "lastname": "Doe", + "email": "john.doe@example.com", + "auth_id": "auth_1", + "user_groups": ["group_1", "group_2"] + }, + { + "user_id": "user_2", + "firstname": "Jane", + "lastname": "Doe", + "email": "jane.doe@example.com", + "auth_id": "auth_2", + "user_groups": ["group_2", "group_3"] + }, + { + "user_id": "user_3", + "firstname": "Jim", + "lastname": "Rock", + "email": "jim.rock@example.com", + "auth_id": "auth_3", + "user_groups": ["group_3", "group_4"] + }, + { + "user_id": "protected_user_id", + "firstname": "Some", + "lastname": "New", + "email": "values.that@should.not", + "auth_id": "take", + "user_groups": ["effect"] + } +] diff --git a/gooddata-pipelines/tests/data/provisioning/entities/users/users_input_incremental_load_deletes_protected_user.json b/gooddata-pipelines/tests/data/provisioning/entities/users/users_input_incremental_load_deletes_protected_user.json new file mode 100644 index 000000000..fec90cbe4 --- /dev/null +++ b/gooddata-pipelines/tests/data/provisioning/entities/users/users_input_incremental_load_deletes_protected_user.json @@ -0,0 +1,47 @@ +[ + { + "user_id": "user_1", + "firstname": "John", + "lastname": "Doe", + "email": "john.doe@example.com", + "auth_id": "auth_1", + "user_groups": ["group_1", "group_2"], + "is_active": true + }, + { + "user_id": "user_2", + "firstname": "Jane", + "lastname": "Doe", + "email": "jane.doe@example.com", + "auth_id": "auth_2", + "user_groups": ["group_2", "group_3"], + "is_active": true + }, + { + "user_id": "user_3", + "firstname": "Jim", + "lastname": "Rock", + "email": "jim.rock@example.com", + "auth_id": "auth_3", + "user_groups": ["group_3", "group_4"], + "is_active": true + }, + { + "user_id": "user_4", + "firstname": "Jack", + "lastname": "Cliff", + "email": "jack.cliff@example.com", + "auth_id": "auth_4", + "user_groups": ["group_4", "group_5"], + "is_active": false + }, + { + "user_id": "protected_user_id", + "firstname": "Some", + "lastname": "New", + "email": "values.that@should.not", + "auth_id": "take", + "user_groups": ["effect"], + "is_active": false + } +] diff --git a/gooddata-pipelines/tests/data/provisioning/entities/users/users_input_incremental_load_modifies_protected_user.json b/gooddata-pipelines/tests/data/provisioning/entities/users/users_input_incremental_load_modifies_protected_user.json new file mode 100644 index 000000000..4f7888d76 --- /dev/null +++ b/gooddata-pipelines/tests/data/provisioning/entities/users/users_input_incremental_load_modifies_protected_user.json @@ -0,0 +1,47 @@ +[ + { + "user_id": "user_1", + "firstname": "John", + "lastname": "Doe", + "email": "john.doe@example.com", + "auth_id": "auth_1", + "user_groups": ["group_1", "group_2"], + "is_active": true + }, + { + "user_id": "user_2", + "firstname": "Jane", + "lastname": "Doe", + "email": "jane.doe@example.com", + "auth_id": "auth_2", + "user_groups": ["group_2", "group_3"], + "is_active": true + }, + { + "user_id": "user_3", + "firstname": "Jim", + "lastname": "Rock", + "email": "jim.rock@example.com", + "auth_id": "auth_3", + "user_groups": ["group_3", "group_4"], + "is_active": true + }, + { + "user_id": "user_4", + "firstname": "Jack", + "lastname": "Cliff", + "email": "jack.cliff@example.com", + "auth_id": "auth_4", + "user_groups": ["group_4", "group_5"], + "is_active": false + }, + { + "user_id": "protected_user_id", + "firstname": "Some", + "lastname": "New", + "email": "values.that@should.not", + "auth_id": "take", + "user_groups": ["effect"], + "is_active": true + } +] diff --git a/gooddata-pipelines/tests/provisioning/entities/users/test_users.py b/gooddata-pipelines/tests/provisioning/entities/users/test_users.py index 26cd18a19..65861fae5 100644 --- a/gooddata-pipelines/tests/provisioning/entities/users/test_users.py +++ b/gooddata-pipelines/tests/provisioning/entities/users/test_users.py @@ -15,6 +15,7 @@ CatalogUserGroup, ) from pytest_mock import MockerFixture +from requests import Response from gooddata_pipelines.provisioning.entities.users.models.users import ( UserFullLoad, @@ -195,6 +196,21 @@ def parse_user_data(user_data: list[dict]) -> list[CatalogUser]: "users_expected_incremental_load.json", "incremental_load", ), + ( + "users_input_full_load_modifies_protected_user.json", + "users_expected_full_load.json", + "full_load", + ), + ( + "users_input_incremental_load_modifies_protected_user.json", + "users_expected_incremental_load.json", + "incremental_load", + ), + ( + "users_input_incremental_load_deletes_protected_user.json", + "users_expected_incremental_load.json", + "incremental_load", + ), ], ) def test_user_provisioning( @@ -227,6 +243,26 @@ def test_user_provisioning( return_value=upstream_users, ) + def mock_get_profile(*args, **kwargs) -> Response: + """Mock the get_profile method by creating a response object with sample + response from the API reference. + """ + with open( + f"{TEST_DATA_SUBDIR}/profile_response_content.json", "r" + ) as f: + profile_response = f.read() + response = Response() + response.status_code = 200 + response._content = profile_response.encode("utf-8") + response.headers["Content-Type"] = "application/json" + return response + + mocker.patch.object( + user_provisioner._api, + "get_profile", + side_effect=mock_get_profile, + ) + upstream_user_cache = {user.id: user for user in upstream_users} def patch_get_user(user_id: str): diff --git a/gooddata-pipelines/tests/provisioning/test_provisioning.py b/gooddata-pipelines/tests/provisioning/test_provisioning.py index aaaf423ab..2c838327b 100644 --- a/gooddata-pipelines/tests/provisioning/test_provisioning.py +++ b/gooddata-pipelines/tests/provisioning/test_provisioning.py @@ -1,4 +1,5 @@ # (C) 2025 GoodData Corporation +import os from pathlib import Path import pytest @@ -110,9 +111,11 @@ def test_fail_type_validation( def test_create_from_profile() -> None: """Test creating a provisioner from a profile.""" + + os.environ["MOCK_TOKEN"] = "some_user_token" provisioner: Provisioning = Provisioning.create_from_profile( profile="mock_profile", profiles_path=Path(f"{TEST_DATA_DIR}/profiles.yaml"), ) assert provisioner._api._domain == "http://localhost:3000" - assert provisioner._api._token == "some_user_token" + assert provisioner._api._token == os.environ.pop("MOCK_TOKEN")