From 0bc617fd6c010c79b1b058201a35ed2a128559bf Mon Sep 17 00:00:00 2001 From: Yiyang Li Date: Wed, 3 Aug 2022 14:15:57 -0700 Subject: [PATCH] Source Okta: add permission stream under a custom role (#14739) * Source Okta: add permission stream under a custom role - it supports full refresh only - add unit tests * bump connector version * bump connector version in Dockerfile * auto-bump connector version [ci skip] Co-authored-by: sajarin Co-authored-by: Octavia Squidington III --- .../resources/seed/source_definitions.yaml | 2 +- .../src/main/resources/seed/source_specs.yaml | 2 +- .../bases/source-acceptance-test/setup.py | 2 + .../connectors/source-okta/Dockerfile | 2 +- .../connectors/source-okta/README.md | 65 +++++++++++---- .../integration_tests/catalog.json | 9 +++ .../integration_tests/configured_catalog.json | 10 +++ .../source-okta/sample_files/catalog.json | 19 +++++ .../source_okta/schemas/custom_roles.json | 1 + .../source_okta/schemas/permissions.json | 79 +++++++++++++++++++ .../source-okta/source_okta/source.py | 24 ++++++ .../source-okta/unit_tests/conftest.py | 18 +++++ .../source-okta/unit_tests/test_streams.py | 17 ++++ docs/integrations/sources/okta.md | 4 +- 14 files changed, 236 insertions(+), 18 deletions(-) create mode 100644 airbyte-integrations/connectors/source-okta/source_okta/schemas/permissions.json diff --git a/airbyte-config/init/src/main/resources/seed/source_definitions.yaml b/airbyte-config/init/src/main/resources/seed/source_definitions.yaml index 7bc36a411687..e687dc3896e1 100644 --- a/airbyte-config/init/src/main/resources/seed/source_definitions.yaml +++ b/airbyte-config/init/src/main/resources/seed/source_definitions.yaml @@ -628,7 +628,7 @@ - name: Okta sourceDefinitionId: 1d4fdb25-64fc-4569-92da-fcdca79a8372 dockerRepository: airbyte/source-okta - dockerImageTag: 0.1.10 + dockerImageTag: 0.1.11 documentationUrl: https://docs.airbyte.io/integrations/sources/okta icon: okta.svg sourceType: api diff --git a/airbyte-config/init/src/main/resources/seed/source_specs.yaml b/airbyte-config/init/src/main/resources/seed/source_specs.yaml index f5f2268fa37a..a8c1a1b4773f 100644 --- a/airbyte-config/init/src/main/resources/seed/source_specs.yaml +++ b/airbyte-config/init/src/main/resources/seed/source_specs.yaml @@ -6033,7 +6033,7 @@ - - "client_secret" oauthFlowOutputParameters: - - "access_token" -- dockerImage: "airbyte/source-okta:0.1.10" +- dockerImage: "airbyte/source-okta:0.1.11" spec: documentationUrl: "https://docs.airbyte.io/integrations/sources/okta" connectionSpecification: diff --git a/airbyte-integrations/bases/source-acceptance-test/setup.py b/airbyte-integrations/bases/source-acceptance-test/setup.py index 00d6f13e59b9..dfffee08a770 100644 --- a/airbyte-integrations/bases/source-acceptance-test/setup.py +++ b/airbyte-integrations/bases/source-acceptance-test/setup.py @@ -20,6 +20,8 @@ "dpath~=2.0.1", "jsonschema~=3.2.0", "jsonref==0.2", + "requests-mock", + "pytest-mock~=3.6.1", ] setuptools.setup( diff --git a/airbyte-integrations/connectors/source-okta/Dockerfile b/airbyte-integrations/connectors/source-okta/Dockerfile index ba3c88dcb951..dd7e82b47580 100644 --- a/airbyte-integrations/connectors/source-okta/Dockerfile +++ b/airbyte-integrations/connectors/source-okta/Dockerfile @@ -12,5 +12,5 @@ RUN pip install . ENV AIRBYTE_ENTRYPOINT "python /airbyte/integration_code/main.py" ENTRYPOINT ["python", "/airbyte/integration_code/main.py"] -LABEL io.airbyte.version=0.1.10 +LABEL io.airbyte.version=0.1.11 LABEL io.airbyte.name=airbyte/source-okta diff --git a/airbyte-integrations/connectors/source-okta/README.md b/airbyte-integrations/connectors/source-okta/README.md index 7c5a29094da3..62dc873f4618 100644 --- a/airbyte-integrations/connectors/source-okta/README.md +++ b/airbyte-integrations/connectors/source-okta/README.md @@ -6,20 +6,25 @@ For information about how to use this connector within Airbyte, see [the documen ## Local development ### Prerequisites + **To iterate on this connector, make sure to complete this prerequisites section.** #### Build & Activate Virtual Environment and install dependencies + From this connector directory, create a virtual environment: -``` + +```shell python -m venv .venv ``` This will generate a virtualenv for this module in `.venv/`. Make sure this venv is active in your development environment of choice. To activate it from the terminal, run: -``` + +```shell source .venv/bin/activate pip install -r requirements.txt ``` + If you are in an IDE, follow your IDE's instructions to activate the virtualenv. Note that while we are installing dependencies from `requirements.txt`, you should only edit `setup.py` for your dependencies. `requirements.txt` is @@ -28,14 +33,17 @@ If this is mumbo jumbo to you, don't worry about it, just put your deps in `setu should work as you expect. #### Building via Gradle + You can also build the connector in Gradle. This is typically used in CI and not needed for your development workflow. To build using Gradle, from the Airbyte repository root, run: -``` + +```shell ./gradlew :airbyte-integrations:connectors:source-okta:build ``` #### Create credentials + **If you are a community contributor**, follow the instructions in the [documentation](https://docs.airbyte.io/integrations/sources/okta) to generate the necessary credentials. Then create a file `secrets/config.json` conforming to the `source_okta/spec.json` file. Note that any directory named `secrets` is gitignored across the entire Airbyte repo, so there is no danger of accidentally checking in sensitive information. @@ -45,7 +53,8 @@ See `integration_tests/sample_config.json` for a sample config file. and place them into `secrets/config.json`. ### Locally running the connector -``` + +```shell python main.py spec python main.py check --config secrets/config.json python main.py discover --config secrets/config.json @@ -55,76 +64,104 @@ python main.py read --config secrets/config.json --catalog integration_tests/con ### Locally running the connector docker image #### Build + First, make sure you build the latest Docker image: -``` + +```shell docker build . -t airbyte/source-okta:dev ``` You can also build the connector image via Gradle: -``` + +```shell ./gradlew :airbyte-integrations:connectors:source-okta:airbyteDocker ``` + When building via Gradle, the docker image name and tag, respectively, are the values of the `io.airbyte.name` and `io.airbyte.version` `LABEL`s in the Dockerfile. #### Run + Then run any of the connector commands as follows: -``` + +```shell docker run --rm airbyte/source-okta:dev spec docker run --rm -v $(pwd)/secrets:/secrets airbyte/source-okta:dev check --config /secrets/config.json docker run --rm -v $(pwd)/secrets:/secrets airbyte/source-okta:dev discover --config /secrets/config.json docker run --rm -v $(pwd)/secrets:/secrets -v $(pwd)/integration_tests:/integration_tests airbyte/source-okta:dev read --config /secrets/config.json --catalog /integration_tests/configured_catalog.json ``` + ## Testing + Make sure to familiarize yourself with [pytest test discovery](https://docs.pytest.org/en/latest/goodpractices.html#test-discovery) to know how your test files and methods should be named. First install test dependencies into your virtual environment: -``` + +```shell pip install .[tests] ``` + ### Unit Tests + To run unit tests locally, from the connector directory run: -``` + +```shell python -m pytest unit_tests ``` ### Integration Tests + There are two types of integration tests: Acceptance Tests (Airbyte's test suite for all source connectors) and custom integration tests (which are specific to this connector). + #### Custom Integration tests -Place custom tests inside `integration_tests/` folder, then, from the connector root, run + +Place custom tests inside the `integration_tests``/` folder, then, from the connector root, run + ``` python -m pytest integration_tests ``` + #### Acceptance Tests + Customize `acceptance-test-config.yml` file to configure tests. See [Source Acceptance Tests](https://docs.airbyte.io/connector-development/testing-connectors/source-acceptance-tests-reference) for more information. If your connector requires to create or destroy resources for use during acceptance tests create fixtures for it and place them inside integration_tests/acceptance.py. To run your integration tests with acceptance tests, from the connector root, run + ``` docker build . --no-cache -t airbyte/source-okta:dev \ && python -m pytest -p source_acceptance_test.plugin ``` + To run your integration tests with docker ### Using gradle to run tests + All commands should be run from airbyte project root. To run unit tests: + ``` ./gradlew :airbyte-integrations:connectors:source-okta:unitTest ``` + To run acceptance and custom integration tests: + ``` ./gradlew :airbyte-integrations:connectors:source-okta:integrationTest ``` ## Dependency Management + All of your dependencies should go in `setup.py`, NOT `requirements.txt`. The requirements file is only used to connect internal Airbyte dependencies in the monorepo for local development. We split dependencies between two groups, dependencies that are: + * required for your connector to work need to go to `MAIN_REQUIREMENTS` list. * required for the testing need to go to `TEST_REQUIREMENTS` list ### Publishing a new version of the connector + You've checked out the repo, implemented a million dollar feature, and you're ready to share your changes with the world. Now what? + 1. Make sure your changes are passing unit and integration tests. -1. Bump the connector version in `Dockerfile` -- just increment the value of the `LABEL io.airbyte.version` appropriately (we use [SemVer](https://semver.org/)). -1. Create a Pull Request. -1. Pat yourself on the back for being an awesome contributor. -1. Someone from Airbyte will take a look at your PR and iterate with you to merge it into master. +2. Bump the connector version in `Dockerfile` -- just increment the value of the `LABEL io.airbyte.version` appropriately (we use [SemVer](https://semver.org/)). +3. Create a Pull Request. +4. Pat yourself on the back for being an awesome contributor. +5. Someone from Airbyte will take a look at your PR and iterate with you to merge it into master. diff --git a/airbyte-integrations/connectors/source-okta/integration_tests/catalog.json b/airbyte-integrations/connectors/source-okta/integration_tests/catalog.json index 5d4472188523..b671245129cf 100644 --- a/airbyte-integrations/connectors/source-okta/integration_tests/catalog.json +++ b/airbyte-integrations/connectors/source-okta/integration_tests/catalog.json @@ -66,6 +66,15 @@ "sync_mode": "full_refresh", "destination_sync_mode": "overwrite", "primary_key": [["id"]] + }, + { + "stream": { + "name": "permissions", + "json_schema": {} + }, + "sync_mode": "full_refresh", + "destination_sync_mode": "overwrite", + "primary_key": [["label"]] } ] } diff --git a/airbyte-integrations/connectors/source-okta/integration_tests/configured_catalog.json b/airbyte-integrations/connectors/source-okta/integration_tests/configured_catalog.json index 7e146a15260f..14c4218507f8 100644 --- a/airbyte-integrations/connectors/source-okta/integration_tests/configured_catalog.json +++ b/airbyte-integrations/connectors/source-okta/integration_tests/configured_catalog.json @@ -70,6 +70,16 @@ }, "sync_mode": "full_refresh", "destination_sync_mode": "overwrite" + }, + { + "stream": { + "name": "permissions", + "json_schema": {}, + "supported_sync_modes": ["full_refresh"] + }, + "sync_mode": "full_refresh", + "destination_sync_mode": "overwrite", + "primary_key": [["label"]] } ] } diff --git a/airbyte-integrations/connectors/source-okta/sample_files/catalog.json b/airbyte-integrations/connectors/source-okta/sample_files/catalog.json index 468a300fac08..2f02d8394ea9 100644 --- a/airbyte-integrations/connectors/source-okta/sample_files/catalog.json +++ b/airbyte-integrations/connectors/source-okta/sample_files/catalog.json @@ -60,6 +60,25 @@ "sync_mode": "full_refresh", "destination_sync_mode": "overwrite", "primary_key": [["id"]] + }, + { + "stream": { + "name": "custom_roles", + "json_schema": {} + }, + "sync_mode": "full_refresh", + "destination_sync_mode": "overwrite", + "primary_key": [["id"]] + }, + { + "stream": { + "name": "permissions", + "json_schema": {}, + "supported_sync_modes": ["full_refresh"] + }, + "sync_mode": "full_refresh", + "destination_sync_mode": "overwrite", + "primary_key": [["label"]] } ] } diff --git a/airbyte-integrations/connectors/source-okta/source_okta/schemas/custom_roles.json b/airbyte-integrations/connectors/source-okta/source_okta/schemas/custom_roles.json index ce7e87bf181e..f696a1139aa4 100644 --- a/airbyte-integrations/connectors/source-okta/source_okta/schemas/custom_roles.json +++ b/airbyte-integrations/connectors/source-okta/source_okta/schemas/custom_roles.json @@ -1,6 +1,7 @@ { "type": ["null", "object"], "additionalProperties": true, + "description": "Gets a paginated list of Custom Roles", "properties": { "id": { "type": ["null", "string"] diff --git a/airbyte-integrations/connectors/source-okta/source_okta/schemas/permissions.json b/airbyte-integrations/connectors/source-okta/source_okta/schemas/permissions.json new file mode 100644 index 000000000000..e3c68176645c --- /dev/null +++ b/airbyte-integrations/connectors/source-okta/source_okta/schemas/permissions.json @@ -0,0 +1,79 @@ +{ + "description": "Gets the list of permissions included in a Custom Role identified by its id", + "properties": { + "label": { + "enum": [ + "okta.users.manage", + "okta.users.create", + "okta.users.read", + "okta.users.credentials.manage", + "okta.users.credentials.resetFactors", + "okta.users.credentials.resetPassword", + "okta.users.credentials.expirePassword", + "okta.users.userprofile.manage", + "okta.users.lifecycle.manage", + "okta.users.lifecycle.activate", + "okta.users.lifecycle.deactivate", + "okta.users.lifecycle.suspend", + "okta.users.lifecycle.unsuspend", + "okta.users.lifecycle.delete", + "okta.users.lifecycle.unlock", + "okta.users.lifecycle.clearSessions", + "okta.users.groupMembership.manage", + "okta.users.appAssignment.manage", + "okta.groups.manage", + "okta.groups.create", + "okta.groups.members.manage", + "okta.groups.read", + "okta.groups.appAssignment.manage", + "okta.apps.read", + "okta.apps.manage", + "okta.apps.assignment.manage", + "okta.profilesources.import.run", + "okta.authzServers.read", + "okta.authzServers.manage", + "okta.customizations.read", + "okta.customizations.manage", + "okta.workflows.invoke" + ], + "type": "string", + "description": "Type of permissions" + }, + "created": { + "format": "date-time", + "type": "string" + }, + "lastUpdated": { + "format": "date-time", + "type": "string" + }, + "_links": { + "properties": { + "assignee": { + "properties": { + "self": { + "type": ["null", "object"], + "additionalProperties": true, + "properties": { + "href": { + "type": ["null", "string"] + } + } + }, + "role": { + "type": ["null", "object"], + "additionalProperties": true, + "properties": { + "href": { + "type": ["null", "string"] + } + } + } + } + } + }, + "type": ["object", "null"] + } + }, + "type": "object" +} diff --git a/airbyte-integrations/connectors/source-okta/source_okta/source.py b/airbyte-integrations/connectors/source-okta/source_okta/source.py index 2a119e62fc44..5f441f3888bb 100644 --- a/airbyte-integrations/connectors/source-okta/source_okta/source.py +++ b/airbyte-integrations/connectors/source-okta/source_okta/source.py @@ -223,6 +223,7 @@ def request_params( class CustomRoles(OktaStream): + # https://developer.okta.com/docs/reference/api/roles/#list-roles primary_key = "id" def path(self, **kwargs) -> str: @@ -250,6 +251,28 @@ def path(self, stream_slice: Mapping[str, Any] = None, **kwargs) -> str: return f"users/{user_id}/roles" +class Permissions(OktaStream): + # https://developer.okta.com/docs/reference/api/roles/#list-permissions + primary_key = "label" + use_cache = True + + def parse_response( + self, + response: requests.Response, + **kwargs, + ) -> Iterable[Mapping]: + yield from response.json()["permissions"] + + def stream_slices(self, **kwargs): + custom_roles = CustomRoles(authenticator=self.authenticator, url_base=self.url_base) + for role in custom_roles.read_records(sync_mode=SyncMode.full_refresh): + yield {"role_id": role["id"]} + + def path(self, stream_slice: Mapping[str, Any] = None, **kwargs) -> str: + role_id = stream_slice["role_id"] + return f"iam/roles/{role_id}/permissions" + + class OktaOauth2Authenticator(Oauth2Authenticator): def get_refresh_request_body(self) -> Mapping[str, Any]: return { @@ -342,4 +365,5 @@ def streams(self, config: Mapping[str, Any]) -> List[Stream]: CustomRoles(**initialization_params), UserRoleAssignments(**initialization_params), GroupRoleAssignments(**initialization_params), + Permissions(**initialization_params), ] diff --git a/airbyte-integrations/connectors/source-okta/unit_tests/conftest.py b/airbyte-integrations/connectors/source-okta/unit_tests/conftest.py index ba48acb1a1ae..de7b6e592876 100644 --- a/airbyte-integrations/connectors/source-okta/unit_tests/conftest.py +++ b/airbyte-integrations/connectors/source-okta/unit_tests/conftest.py @@ -143,6 +143,24 @@ def custom_role_instance(api_url): } +@pytest.fixture() +def permission_instance(api_url): + """ + Custom Role instance object response + """ + _role_id = "test_role_id" + _id = "okta.users.read" + return { + "label": _id, + "created": "2022-07-09T20:54:54.000Z", + "lastUpdated": "2022-07-09T20:54:54.000Z", + "_links": { + "role": {"href": f"{api_url}/api/v1/iam/roles/{_role_id}"}, + "self": {"href": f"{api_url}/api/v1/iam/roles/{_role_id}/permissions/{_id}"}, + }, + } + + @pytest.fixture() def groups_instance(api_url): """ diff --git a/airbyte-integrations/connectors/source-okta/unit_tests/test_streams.py b/airbyte-integrations/connectors/source-okta/unit_tests/test_streams.py index 962acb077cc5..bf744d314601 100644 --- a/airbyte-integrations/connectors/source-okta/unit_tests/test_streams.py +++ b/airbyte-integrations/connectors/source-okta/unit_tests/test_streams.py @@ -19,6 +19,7 @@ IncrementalOktaStream, Logs, OktaStream, + Permissions, UserRoleAssignments, Users, ) @@ -212,6 +213,22 @@ def test_custom_roles_parse_response(self, requests_mock, patch_base_class, cust assert list(stream.parse_response(response=requests.get(f"{api_url}"))) == [custom_role_instance] +class TestStreamPermissions: + def test_permissions(self, requests_mock, patch_base_class, permission_instance, url_base, api_url): + stream = Permissions(url_base=url_base) + record = {"permissions": [permission_instance]} + role_id = "test_role_id" + requests_mock.get(f"{api_url}/iam/roles/{role_id}/permissions", json=record) + inputs = {"sync_mode": SyncMode.full_refresh, "stream_state": {}, "stream_slice": {"role_id": role_id}} + assert list(stream.read_records(**inputs)) == record["permissions"] + + def test_permissions_parse_response(self, requests_mock, patch_base_class, permission_instance, url_base, api_url): + stream = Permissions(url_base=url_base) + record = {"permissions": [permission_instance]} + requests_mock.get(f"{api_url}", json=record) + assert list(stream.parse_response(response=requests.get(f"{api_url}"))) == [permission_instance] + + class TestStreamGroups: def test_groups(self, requests_mock, patch_base_class, groups_instance, url_base, api_url): stream = Groups(url_base=url_base) diff --git a/docs/integrations/sources/okta.md b/docs/integrations/sources/okta.md index 7ef4ca7f5dda..4149720f3c1a 100644 --- a/docs/integrations/sources/okta.md +++ b/docs/integrations/sources/okta.md @@ -15,6 +15,7 @@ This Source is capable of syncing the following core Streams: - [Group Role Assignments](https://developer.okta.com/docs/reference/api/roles/#list-roles-assigned-to-a-group) - [System Log](https://developer.okta.com/docs/reference/api/system-log/#get-started) - [Custom Roles](https://developer.okta.com/docs/reference/api/roles/#list-roles) +- [Permissions](https://developer.okta.com/docs/reference/api/roles/#list-permissions) ### Data type mapping @@ -61,7 +62,8 @@ Different Okta APIs require different admin privilege levels. API tokens inherit | Version | Date | Pull Request | Subject | |:--------|:-----------|:---------------------------------------------------------|:-------------------------------------------------------------------------------| -| 0.1.10 | 2022-08-01 | [15179](https://github.com/airbytehq/airbyte/pull/15179) | Fixed broken schemas for all streams +| 0.1.11 | 2022-08-03 | [14739](https://github.com/airbytehq/airbyte/pull/14739) | add permissions for custom roles | +| 0.1.10 | 2022-08-01 | [15179](https://github.com/airbytehq/airbyte/pull/15179) | Fixed broken schemas for all streams | 0.1.9 | 2022-07-25 | [15001](https://github.com/airbytehq/airbyte/pull/15001) | Return deprovisioned users | | 0.1.8 | 2022-07-19 | [14710](https://github.com/airbytehq/airbyte/pull/14710) | Implement OAuth2.0 authorization method | | 0.1.7 | 2022-07-13 | [14556](https://github.com/airbytehq/airbyte/pull/14556) | add User_Role_Assignments and Group_Role_Assignments streams (full fetch only) |