From e5b0312e3eed61059368d698b76380504197156b Mon Sep 17 00:00:00 2001 From: Hiroyuki Wada Date: Thu, 14 Nov 2024 16:50:12 +0900 Subject: [PATCH] feat: initial implementation BREAKING CHANGE: initial release --- .github/workflows/pull_request.yml | 38 + .github/workflows/release.yml | 50 ++ .gitignore | 5 + .releaserc | 36 + LICENSE | 201 +++++ README.md | 28 + pom.xml | 338 +++++++ src/main/assembly/connector.xml | 43 + .../AtlassianGuardConfiguration.java | 233 +++++ .../atlassian/AtlassianGuardConnector.java | 299 +++++++ .../atlassian/AtlassianGuardFilter.java | 57 ++ .../AtlassianGuardFilterTranslator.java | 82 ++ .../atlassian/AtlassianGuardGroupHandler.java | 222 +++++ .../atlassian/AtlassianGuardGroupModel.java | 64 ++ .../atlassian/AtlassianGuardRESTClient.java | 314 +++++++ .../atlassian/AtlassianGuardSchema.java | 75 ++ .../atlassian/AtlassianGuardUserHandler.java | 400 +++++++++ .../atlassian/AtlassianGuardUserModel.java | 92 ++ .../atlassian/PatchOperationsModel.java | 122 +++ .../connector/util/AbstractRESTClient.java | 429 +++++++++ .../connector/util/ObjectHandler.java | 60 ++ .../connector/util/QueryHandler.java | 6 + .../connector/util/SchemaDefinition.java | 763 ++++++++++++++++ .../jp/openstandia/connector/util/Utils.java | 199 +++++ .../atlassian/AtlassianGuardUtilsTest.java | 80 ++ .../connector/atlassian/GroupTest.java | 615 +++++++++++++ .../connector/atlassian/SchemaTest.java | 98 ++ .../connector/atlassian/TestTest.java | 27 + .../connector/atlassian/UserTest.java | 835 ++++++++++++++++++ .../atlassian/testutil/AbstractTest.java | 162 ++++ .../LocalAtlassianGuardConnector.java | 25 + .../atlassian/testutil/MockClient.java | 169 ++++ 32 files changed, 6167 insertions(+) create mode 100644 .github/workflows/pull_request.yml create mode 100644 .github/workflows/release.yml create mode 100644 .gitignore create mode 100644 .releaserc create mode 100644 LICENSE create mode 100644 README.md create mode 100644 pom.xml create mode 100644 src/main/assembly/connector.xml create mode 100644 src/main/java/jp/openstandia/connector/atlassian/AtlassianGuardConfiguration.java create mode 100644 src/main/java/jp/openstandia/connector/atlassian/AtlassianGuardConnector.java create mode 100644 src/main/java/jp/openstandia/connector/atlassian/AtlassianGuardFilter.java create mode 100644 src/main/java/jp/openstandia/connector/atlassian/AtlassianGuardFilterTranslator.java create mode 100644 src/main/java/jp/openstandia/connector/atlassian/AtlassianGuardGroupHandler.java create mode 100644 src/main/java/jp/openstandia/connector/atlassian/AtlassianGuardGroupModel.java create mode 100644 src/main/java/jp/openstandia/connector/atlassian/AtlassianGuardRESTClient.java create mode 100644 src/main/java/jp/openstandia/connector/atlassian/AtlassianGuardSchema.java create mode 100644 src/main/java/jp/openstandia/connector/atlassian/AtlassianGuardUserHandler.java create mode 100644 src/main/java/jp/openstandia/connector/atlassian/AtlassianGuardUserModel.java create mode 100644 src/main/java/jp/openstandia/connector/atlassian/PatchOperationsModel.java create mode 100644 src/main/java/jp/openstandia/connector/util/AbstractRESTClient.java create mode 100644 src/main/java/jp/openstandia/connector/util/ObjectHandler.java create mode 100644 src/main/java/jp/openstandia/connector/util/QueryHandler.java create mode 100644 src/main/java/jp/openstandia/connector/util/SchemaDefinition.java create mode 100644 src/main/java/jp/openstandia/connector/util/Utils.java create mode 100644 src/test/java/jp/openstandia/connector/atlassian/AtlassianGuardUtilsTest.java create mode 100644 src/test/java/jp/openstandia/connector/atlassian/GroupTest.java create mode 100644 src/test/java/jp/openstandia/connector/atlassian/SchemaTest.java create mode 100644 src/test/java/jp/openstandia/connector/atlassian/TestTest.java create mode 100644 src/test/java/jp/openstandia/connector/atlassian/UserTest.java create mode 100644 src/test/java/jp/openstandia/connector/atlassian/testutil/AbstractTest.java create mode 100644 src/test/java/jp/openstandia/connector/atlassian/testutil/LocalAtlassianGuardConnector.java create mode 100644 src/test/java/jp/openstandia/connector/atlassian/testutil/MockClient.java diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml new file mode 100644 index 0000000..32f5dd5 --- /dev/null +++ b/.github/workflows/pull_request.yml @@ -0,0 +1,38 @@ +name: Build and test for the pull request + +on: + pull_request: + types: [opened, synchronize] + +jobs: + build: + name: Build and test + runs-on: ubuntu-latest + if: "!contains(github.event.head_commit.message, '[ci skip]')" + steps: + - name: Checkout sources + uses: actions/checkout@v4 + + - name: Set up JDK 11 + uses: actions/setup-java@v4 + with: + distribution: 'corretto' + java-version: 11 + cache: 'maven' + server-id: ossrh + server-username: OSSRH_JIRA_USERNAME + server-password: OSSRH_JIRA_PASSWORD + gpg-private-key: ${{ secrets.OSSRH_GPG_SECRET_KEY }} + gpg-passphrase: OSSRH_GPG_SECRET_KEY_PASSWORD + + - name: Build with Maven + run: mvn -B package + + - name: Deploy SNAPSHOT version + run: mvn -B -DskipTests deploy + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + OSSRH_JIRA_USERNAME: ${{ secrets.OSSRH_JIRA_USERNAME }} + OSSRH_JIRA_PASSWORD: ${{ secrets.OSSRH_JIRA_PASSWORD }} + OSSRH_GPG_SECRET_KEY_PASSWORD: ${{ secrets.OSSRH_GPG_SECRET_KEY_PASSWORD }} + diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..c654698 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,50 @@ +name: Release package to the Maven Central Repository + +on: + push: + branches: + - main + +jobs: + build: + name: Build and release + runs-on: ubuntu-latest + if: "!contains(github.event.head_commit.message, '[ci skip]')" + steps: + - name: Checkout sources + uses: actions/checkout@v4 + with: + # Disabling shallow clone is needed for correctly determing next release with semantic release + fetch-depth: 0 + persist-credentials: false + + - name: Set up JDK + uses: actions/setup-java@v4 + with: + distribution: 'corretto' + java-version: 11 + cache: 'maven' + server-id: ossrh + server-username: OSSRH_JIRA_USERNAME + server-password: OSSRH_JIRA_PASSWORD + gpg-private-key: ${{ secrets.OSSRH_GPG_SECRET_KEY }} + gpg-passphrase: OSSRH_GPG_SECRET_KEY_PASSWORD + + - name: Test + run: mvn -B test + + - name: Semantic release + id: semantic + uses: cycjimmy/semantic-release-action@v4 + with: + semantic_version: 23 + extra_plugins: | + @semantic-release/changelog@6 + @terrestris/maven-semantic-release@2 + @semantic-release/git@10 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + OSSRH_JIRA_USERNAME: ${{ secrets.OSSRH_JIRA_USERNAME }} + OSSRH_JIRA_PASSWORD: ${{ secrets.OSSRH_JIRA_PASSWORD }} + OSSRH_GPG_SECRET_KEY_PASSWORD: ${{ secrets.OSSRH_GPG_SECRET_KEY_PASSWORD }} + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c2ebb29 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +target +*.jar +.idea +.DS_Store + diff --git a/.releaserc b/.releaserc new file mode 100644 index 0000000..66ff0c5 --- /dev/null +++ b/.releaserc @@ -0,0 +1,36 @@ +{ + "branches": [ + "main" + ], + "plugins": [ + "@semantic-release/commit-analyzer", + "@semantic-release/release-notes-generator", + "@semantic-release/changelog", + [ + "@terrestris/maven-semantic-release", + { + "mavenTarget": "deploy", + "clean": false, + "updateSnapshotVersion": true, + "settingsPath": "/home/runner/.m2/settings.xml", + "processAllModules": true + } + ], + [ + "@semantic-release/git", + { + "assets": [ + "CHANGELOG.md", "pom.xml", "**/pom.xml" + ], + "message": "chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}" + } + ], + [ + "@semantic-release/github", + { + "successComment": false, + "failTitle": false + } + ] + ] +} diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md new file mode 100644 index 0000000..1ad0b80 --- /dev/null +++ b/README.md @@ -0,0 +1,28 @@ +# Atlassian Guard Connector + +## Description + +Connector for [Atlassian Guard](https://www.atlassian.com/software/guard). + +## Capabilities and Features + +- Schema: YES +- Provisioning: YES +- Live Synchronization: No +- Password: No +- Activation: Yes +- Script execution: No + +## Build + +Install JDK 11+ and [maven3](https://maven.apache.org/download.cgi) then build: + +``` +mvn install +``` + +After successful the build, you can find `connector-atlassian-guard-*.jar` in `target` directory. + +## License + +Licensed under the [Apache License 2.0](/LICENSE). diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..4509484 --- /dev/null +++ b/pom.xml @@ -0,0 +1,338 @@ + + 4.0.0 + + + connector-parent + com.evolveum.polygon + 1.5.0.0 + + + + jp.openstandia.connector + connector-atlassian-guard + 0.0.2-SNAPSHOT + jar + + Atlassian Guard Connector + + + Atlassian Guard Connector. + + https://github.com/openstandia/connector-atlassian-guard + + + + Apache License, Version 2.0 + https://www.apache.org/licenses/LICENSE-2.0 + repo + + + + + + wadahiro + Hiroyuki Wada + h2-wada@nri.co.jp + Nomura Research Institute, Ltd. + +9 + + + + + scm:git:https://github.com/openstandia/connector-atlassian-guard.git + scm:git:https://github.com/openstandia/connector-atlassian-guard.git + https://github.com/openstandia/connector-atlassian-guard + + + + + ossrh + https://oss.sonatype.org/content/repositories/snapshots + + + ossrh + https://oss.sonatype.org/service/local/staging/deploy/maven2/ + + + + + jp.openstandia.connector.atlassian-guard + AtlassianGuardConnector + + + + + central + Maven Central + https://repo1.maven.org/maven2 + + + evolveum-nexus-releases + Internal Releases + https://nexus.evolveum.com/nexus/content/repositories/releases/ + + + evolveum-nexus-snapshots + Internal Releases + https://nexus.evolveum.com/nexus/content/repositories/snapshots/ + + + + + + + org.apache.maven.plugins + maven-assembly-plugin + + + org.apache.maven.plugins + maven-compiler-plugin + + + org.apache.maven.plugins + maven-resources-plugin + + + org.apache.maven.plugins + maven-surefire-plugin + 2.22.2 + + + org.apache.maven.surefire + surefire-junit-platform + 2.22.2 + + + + + + org.sonatype.plugins + nexus-staging-maven-plugin + 1.6.8 + true + + ossrh + https://oss.sonatype.org/ + true + 20 + + + + org.apache.maven.plugins + maven-source-plugin + 3.2.0 + + + attach-sources + verify + + jar-no-fork + + + + + + org.apache.maven.plugins + maven-javadoc-plugin + 3.2.0 + + ${project.source.version} + ${java.home}/bin/javadoc + + + + attach-javadocs + + jar + + + + + + org.apache.maven.plugins + maven-gpg-plugin + 1.6 + + + --pinentry-mode + loopback + + + + + sign-artifacts + verify + + sign + + + + + + + + + + com.squareup.okhttp3 + okhttp + 4.12.0 + + + com.squareup.okhttp3 + okhttp-urlconnection + 4.12.0 + + + com.fasterxml.jackson.core + jackson-databind + 2.17.0 + + + + org.junit.jupiter + junit-jupiter + 5.6.0 + test + + + + + + midpoint48 + + + com.evolveum.midpoint.gui + admin-gui + 4.8.2 + provided + + + org.testng + testng + test + + + org.yaml + snakeyaml + + + + + net.tirasa.connid + connector-framework + 1.5.2.0 + provided + + + net.tirasa.connid + connector-framework-internal + 1.5.2.0 + provided + + + + + midpoint44 + + + com.evolveum.midpoint.gui + admin-gui + jar + classes + 4.4.8 + provided + + + org.testng + testng + test + + + org.yaml + snakeyaml + + + + + net.tirasa.connid + connector-framework + 1.5.1.10 + provided + + + net.tirasa.connid + connector-framework-internal + 1.5.1.10 + provided + + + net.tirasa.connid + connector-framework-contract + 1.5.1.10 + test + + + + + midpoint40 + + + evolveum-nexus-releases + Internal Releases + https://nexus.evolveum.com/nexus/content/repositories/releases/ + + + evolveum-nexus-snapshots + Internal Releases + https://nexus.evolveum.com/nexus/content/repositories/snapshots/ + + + + jaspersoft-third-party + https://jaspersoft.jfrog.io/jaspersoft/third-party-ce-artifacts + + + + + com.evolveum.midpoint.gui + admin-gui + jar + classes + 4.0.4 + provided + + + org.testng + testng + test + + + org.yaml + snakeyaml + + + + + net.tirasa.connid + connector-framework + 1.5.0.10 + provided + + + net.tirasa.connid + connector-framework-internal + 1.5.0.10 + provided + + + net.tirasa.connid + connector-framework-contract + 1.5.0.10 + test + + + + + diff --git a/src/main/assembly/connector.xml b/src/main/assembly/connector.xml new file mode 100644 index 0000000..14acf75 --- /dev/null +++ b/src/main/assembly/connector.xml @@ -0,0 +1,43 @@ + + + + bundle + + + jar + + + false + + + + ${project.basedir} + + + LICENSE + README.md + + + + ${project.build.directory}/classes + + + + + + + lib + true + false + + net.tirasa.connid:connector-framework + net.tirasa.connid:connector-framework-internal + jp.openstandia.connector:connector-atlassian-guard + + + + + \ No newline at end of file diff --git a/src/main/java/jp/openstandia/connector/atlassian/AtlassianGuardConfiguration.java b/src/main/java/jp/openstandia/connector/atlassian/AtlassianGuardConfiguration.java new file mode 100644 index 0000000..1005c7a --- /dev/null +++ b/src/main/java/jp/openstandia/connector/atlassian/AtlassianGuardConfiguration.java @@ -0,0 +1,233 @@ +/* + * Copyright Nomura Research Institute, Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package jp.openstandia.connector.atlassian; + +import org.identityconnectors.common.security.GuardedString; +import org.identityconnectors.framework.common.exceptions.ConfigurationException; +import org.identityconnectors.framework.spi.AbstractConfiguration; +import org.identityconnectors.framework.spi.ConfigurationProperty; + +import java.util.Arrays; +import java.util.HashSet; +import java.util.Set; +import java.util.stream.Collectors; + +public class AtlassianGuardConfiguration extends AbstractConfiguration { + + private String baseURL; + private GuardedString token; + private String httpProxyHost; + private int httpProxyPort = 3128; + private String httpProxyUser; + private GuardedString httpProxyPassword; + private int defaultQueryPageSize = 50; + private int connectionTimeoutInMilliseconds = 10000; + private int readTimeoutInMilliseconds = 10000; + private int writeTimeoutInMilliseconds = 10000; + private Set ignoreGroup = new HashSet<>(); + private boolean uniqueCheckGroupDisplayNameEnabled = true; + + @ConfigurationProperty( + order = 1, + displayMessageKey = "Atlassian Guard SCIM Base URL", + helpMessageKey = "Atlassian Guard SCIM Base URL.", + required = true, + confidential = false) + public String getBaseURL() { + return baseURL; + } + + public void setBaseURL(String baseURL) { + if (baseURL.endsWith("/")) { + baseURL = baseURL.substring(0, baseURL.lastIndexOf("/")); + } + this.baseURL = baseURL; + } + + @ConfigurationProperty( + order = 2, + displayMessageKey = "Token", + helpMessageKey = "Token for the Atlassian Guard SCIM API.", + required = true, + confidential = true) + public GuardedString getToken() { + return token; + } + + public void setToken(GuardedString token) { + this.token = token; + } + + @ConfigurationProperty( + order = 3, + displayMessageKey = "HTTP Proxy Host", + helpMessageKey = "Hostname for the HTTP Proxy.", + required = false, + confidential = false) + public String getHttpProxyHost() { + return httpProxyHost; + } + + public void setHttpProxyHost(String httpProxyHost) { + this.httpProxyHost = httpProxyHost; + } + + @ConfigurationProperty( + order = 4, + displayMessageKey = "HTTP Proxy Port", + helpMessageKey = "Port for the HTTP Proxy. (Default: 3128)", + required = false, + confidential = false) + public int getHttpProxyPort() { + return httpProxyPort; + } + + public void setHttpProxyPort(int httpProxyPort) { + this.httpProxyPort = httpProxyPort; + } + + @ConfigurationProperty( + order = 5, + displayMessageKey = "HTTP Proxy User", + helpMessageKey = "Username for the HTTP Proxy Authentication.", + required = false, + confidential = false) + public String getHttpProxyUser() { + return httpProxyUser; + } + + public void setHttpProxyUser(String httpProxyUser) { + this.httpProxyUser = httpProxyUser; + } + + @ConfigurationProperty( + order = 6, + displayMessageKey = "HTTP Proxy Password", + helpMessageKey = "Password for the HTTP Proxy Authentication.", + required = false, + confidential = true) + public GuardedString getHttpProxyPassword() { + return httpProxyPassword; + } + + public void setHttpProxyPassword(GuardedString httpProxyPassword) { + this.httpProxyPassword = httpProxyPassword; + } + + @ConfigurationProperty( + order = 7, + displayMessageKey = "Default Query Page Size", + helpMessageKey = "Number of results to return per page. (Default: 50)", + required = false, + confidential = false) + public int getDefaultQueryPageSize() { + return defaultQueryPageSize; + } + + public void setDefaultQueryPageSize(int defaultQueryPageSize) { + this.defaultQueryPageSize = defaultQueryPageSize; + } + + @ConfigurationProperty( + order = 8, + displayMessageKey = "Connection Timeout (in milliseconds)", + helpMessageKey = "Connection timeout when connecting to Atlassian Guard. (Default: 10000)", + required = false, + confidential = false) + public int getConnectionTimeoutInMilliseconds() { + return connectionTimeoutInMilliseconds; + } + + public void setConnectionTimeoutInMilliseconds(int connectionTimeoutInMilliseconds) { + this.connectionTimeoutInMilliseconds = connectionTimeoutInMilliseconds; + } + + @ConfigurationProperty( + order = 9, + displayMessageKey = "Connection Read Timeout (in milliseconds)", + helpMessageKey = "Connection read timeout when connecting to Atlassian Guard. (Default: 10000)", + required = false, + confidential = false) + public int getReadTimeoutInMilliseconds() { + return readTimeoutInMilliseconds; + } + + public void setReadTimeoutInMilliseconds(int readTimeoutInMilliseconds) { + this.readTimeoutInMilliseconds = readTimeoutInMilliseconds; + } + + @ConfigurationProperty( + order = 10, + displayMessageKey = "Connection Write Timeout (in milliseconds)", + helpMessageKey = "Connection write timeout when connecting to Atlassian Guard. (Default: 10000)", + required = false, + confidential = false) + public int getWriteTimeoutInMilliseconds() { + return writeTimeoutInMilliseconds; + } + + public void setWriteTimeoutInMilliseconds(int writeTimeoutInMilliseconds) { + this.writeTimeoutInMilliseconds = writeTimeoutInMilliseconds; + } + + @ConfigurationProperty( + order = 11, + displayMessageKey = "Ignore Group", + helpMessageKey = "Define the group displayName to be ignored when fetching group membership. The displayName is case-insensitive.", + required = false, + confidential = false) + public String[] getIgnoreGroup() { + return ignoreGroup.toArray(new String[0]); + } + + public void setIgnoreGroup(String[] ignoreGroup) { + // To lower case for case-insensitive check + this.ignoreGroup = Arrays.stream(ignoreGroup).map(String::toLowerCase).collect(Collectors.toSet()); + } + + /** + * Returns the configured ignore group set which are converted to lower case for case-insensitive matching. + * + * @return + */ + public Set getIgnoreGroupSet() { + return ignoreGroup; + } + + @ConfigurationProperty( + order = 12, + displayMessageKey = "Unique check group displayName", + helpMessageKey = "When set true, enables the unique check on the displayName of Group during creation. (Default: true)", + required = false, + confidential = false) + public boolean isUniqueCheckGroupDisplayNameEnabled() { + return uniqueCheckGroupDisplayNameEnabled; + } + + public void setUniqueCheckGroupDisplayNameEnabled(boolean uniqueCheckGroupDisplayNameEnabled) { + this.uniqueCheckGroupDisplayNameEnabled = uniqueCheckGroupDisplayNameEnabled; + } + + @Override + public void validate() { + if (baseURL == null) { + throw new ConfigurationException("Atlassian Guard Base URL is required"); + } + if (token == null) { + throw new ConfigurationException("Atlassian Guard token is required"); + } + } +} diff --git a/src/main/java/jp/openstandia/connector/atlassian/AtlassianGuardConnector.java b/src/main/java/jp/openstandia/connector/atlassian/AtlassianGuardConnector.java new file mode 100644 index 0000000..7806e6c --- /dev/null +++ b/src/main/java/jp/openstandia/connector/atlassian/AtlassianGuardConnector.java @@ -0,0 +1,299 @@ +/* + * Copyright Nomura Research Institute, Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package jp.openstandia.connector.atlassian; + +import jp.openstandia.connector.util.ObjectHandler; +import jp.openstandia.connector.util.SchemaDefinition; +import jp.openstandia.connector.util.Utils; +import okhttp3.*; +import org.identityconnectors.common.StringUtil; +import org.identityconnectors.common.logging.Log; +import org.identityconnectors.common.security.GuardedString; +import org.identityconnectors.framework.common.exceptions.*; +import org.identityconnectors.framework.common.objects.*; +import org.identityconnectors.framework.common.objects.filter.FilterTranslator; +import org.identityconnectors.framework.spi.*; +import org.identityconnectors.framework.spi.operations.*; + +import java.io.IOException; +import java.net.InetSocketAddress; +import java.net.Proxy; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +@ConnectorClass(configurationClass = AtlassianGuardConfiguration.class, displayNameKey = "Atlassian Guard Connector") +public class AtlassianGuardConnector implements PoolableConnector, CreateOp, UpdateDeltaOp, DeleteOp, SchemaOp, TestOp, SearchOp, InstanceNameAware { + + private static final Log LOG = Log.getLog(AtlassianGuardConnector.class); + + protected AtlassianGuardConfiguration configuration; + protected AtlassianGuardRESTClient client; + + private AtlassianGuardSchema cachedSchema; + private String instanceName; + + @Override + public Configuration getConfiguration() { + return configuration; + } + + @Override + public void init(Configuration configuration) { + this.configuration = (AtlassianGuardConfiguration) configuration; + + try { + authenticateResource(); + } catch (RuntimeException e) { + throw processRuntimeException(e); + } + + LOG.ok("Connector {0} successfully initialized", getClass().getName()); + } + + protected void authenticateResource() { + OkHttpClient.Builder okHttpBuilder = new OkHttpClient.Builder(); + okHttpBuilder.connectTimeout(configuration.getConnectionTimeoutInMilliseconds(), TimeUnit.MILLISECONDS); + okHttpBuilder.readTimeout(configuration.getReadTimeoutInMilliseconds(), TimeUnit.MILLISECONDS); + okHttpBuilder.writeTimeout(configuration.getWriteTimeoutInMilliseconds(), TimeUnit.MILLISECONDS); + okHttpBuilder.addInterceptor(getInterceptor(configuration.getToken())); + + // Setup http proxy aware httpClient + if (StringUtil.isNotEmpty(configuration.getHttpProxyHost())) { + okHttpBuilder.proxy(new Proxy(Proxy.Type.HTTP, + new InetSocketAddress(configuration.getHttpProxyHost(), configuration.getHttpProxyPort()))); + + if (StringUtil.isNotEmpty(configuration.getHttpProxyUser()) && configuration.getHttpProxyPassword() != null) { + configuration.getHttpProxyPassword().access(c -> { + okHttpBuilder.proxyAuthenticator((Route route, Response response) -> { + String credential = Credentials.basic(configuration.getHttpProxyUser(), String.valueOf(c)); + return response.request().newBuilder() + .header("Proxy-Authorization", credential) + .build(); + }); + }); + } + } + + OkHttpClient httpClient = okHttpBuilder.build(); + + client = new AtlassianGuardRESTClient(); + client.init(instanceName, configuration, httpClient); + + // Verify we can access the Atlassian Guard API + client.test(); + } + + private Interceptor getInterceptor(GuardedString accessToken) { + return new Interceptor() { + @Override + public Response intercept(Chain chain) throws IOException { + Request.Builder builder = chain.request().newBuilder() + .addHeader("Accept", "application/json"); + accessToken.access(c -> { + builder.addHeader("Authorization", "Bearer " + String.valueOf(c)); + }); + return chain.proceed(builder.build()); + } + }; + } + + @Override + public Schema schema() { + try { + cachedSchema = new AtlassianGuardSchema(configuration, client); + return cachedSchema.schema; + + } catch (RuntimeException e) { + throw processRuntimeException(e); + } + } + + private ObjectHandler getSchemaHandler(ObjectClass objectClass) { + if (objectClass == null) { + throw new InvalidAttributeValueException("ObjectClass value not provided"); + } + + // Load schema map if it's not loaded yet + if (cachedSchema == null) { + schema(); + } + + ObjectHandler handler = cachedSchema.getSchemaHandler(objectClass); + + if (handler == null) { + throw new InvalidAttributeValueException("Unsupported object class " + objectClass); + } + + return handler; + } + + @Override + public Uid create(ObjectClass objectClass, Set createAttributes, OperationOptions options) { + if (createAttributes == null || createAttributes.isEmpty()) { + throw new InvalidAttributeValueException("Attributes not provided or empty"); + } + + try { + return getSchemaHandler(objectClass).create(createAttributes); + + } catch (RuntimeException e) { + throw processRuntimeException(e); + } + } + + @Override + public Set updateDelta(ObjectClass objectClass, Uid uid, Set modifications, OperationOptions options) { + if (uid == null) { + throw new InvalidAttributeValueException("uid not provided"); + } + + try { + return getSchemaHandler(objectClass).updateDelta(uid, modifications, options); + + } catch (UnknownUidException e) { + LOG.warn("Not found object when updating. instanceName; {0}, objectClass: {1}, uid: {2}", instanceName, objectClass, uid); + throw processRuntimeException(e); + + } catch (RuntimeException e) { + throw processRuntimeException(e); + } + } + + @Override + public void delete(ObjectClass objectClass, Uid uid, OperationOptions options) { + if (uid == null) { + throw new InvalidAttributeValueException("uid not provided"); + } + + try { + getSchemaHandler(objectClass).delete(uid, options); + + } catch (UnknownUidException e) { + LOG.warn("Not found object when deleting. instanceName={0}, objectClass={1}, uid={2}", instanceName, objectClass, uid); + throw processRuntimeException(e); + + } catch (RuntimeException e) { + throw processRuntimeException(e); + } + } + + @Override + public FilterTranslator createFilterTranslator(ObjectClass objectClass, OperationOptions options) { + return new AtlassianGuardFilterTranslator(objectClass, options); + } + + @Override + public void executeQuery(ObjectClass objectClass, AtlassianGuardFilter filter, ResultsHandler resultsHandler, OperationOptions options) { + ObjectHandler schemaHandler = getSchemaHandler(objectClass); + SchemaDefinition schema = schemaHandler.getSchema(); + + int pageSize = Utils.resolvePageSize(options, configuration.getDefaultQueryPageSize()); + int pageOffset = Utils.resolvePageOffset(options); + + // Create full attributesToGet by RETURN_DEFAULT_ATTRIBUTES + ATTRIBUTES_TO_GET + Map attributesToGet = Utils.createFullAttributesToGet(schema, options); + Set returnAttributesSet = attributesToGet.keySet(); + // Collect actual resource fields for fetching (We can them for filtering attributes if the resource supports it) + Set fetchFieldSet = new HashSet<>(attributesToGet.values()); + + boolean allowPartialAttributeValues = Utils.shouldAllowPartialAttributeValues(options); + + int total = 0; + AtomicInteger fetchedCount = new AtomicInteger(); + ResultsHandler countableResultHandler = (connectorObject) -> { + fetchedCount.getAndIncrement(); + return resultsHandler.handle(connectorObject); + }; + + if (filter != null) { + if (filter.isByUid()) { + total = schemaHandler.getByUid((Uid) filter.attributeValue, countableResultHandler, options, + returnAttributesSet, fetchFieldSet, + allowPartialAttributeValues, pageSize, pageOffset); + } else if (filter.isByName()) { + total = schemaHandler.getByName((Name) filter.attributeValue, countableResultHandler, options, + returnAttributesSet, fetchFieldSet, + allowPartialAttributeValues, pageSize, pageOffset); + } else if (filter.isByMembers()) { + total = schemaHandler.getByMembers(filter.attributeValue, countableResultHandler, options, + returnAttributesSet, fetchFieldSet, + allowPartialAttributeValues, pageSize, pageOffset); + } + // No result + } else { + total = schemaHandler.getAll(countableResultHandler, options, + returnAttributesSet, fetchFieldSet, + allowPartialAttributeValues, pageSize, pageOffset); + } + + if (resultsHandler instanceof SearchResultsHandler && + pageOffset > 0) { + + int remaining = total - (pageOffset - 1) - fetchedCount.get(); + + SearchResultsHandler searchResultsHandler = (SearchResultsHandler) resultsHandler; + SearchResult searchResult = new SearchResult(null, remaining); + searchResultsHandler.handleResult(searchResult); + } + } + + @Override + public void test() { + try { + dispose(); + authenticateResource(); + } catch (RuntimeException e) { + throw processRuntimeException(e); + } + } + + @Override + public void dispose() { + client.close(); + this.client = null; + this.cachedSchema = null; + } + + @Override + public void checkAlive() { + // Do nothing + } + + @Override + public void setInstanceName(String instanceName) { + this.instanceName = instanceName; + } + + protected ConnectorException processRuntimeException(RuntimeException e) { + if (e instanceof ConnectorException) { + // Write error log because IDM might not write full stack trace + // It's hard to debug the error + if (e instanceof AlreadyExistsException) { + LOG.warn(e, "Detected the object already exists. instanceName={0}, message={1}", instanceName, e.getMessage()); + } else { + LOG.error(e, "Detected Atlassian Guard connector error. instanceName={0}, message={1}", instanceName, e.getMessage()); + } + return (ConnectorException) e; + } + + LOG.error(e, "Detected Atlassian Guard connector unexpected error. instanceName={0}, message={1}", instanceName, e.getMessage()); + + return new ConnectorIOException(e); + } +} diff --git a/src/main/java/jp/openstandia/connector/atlassian/AtlassianGuardFilter.java b/src/main/java/jp/openstandia/connector/atlassian/AtlassianGuardFilter.java new file mode 100644 index 0000000..045f292 --- /dev/null +++ b/src/main/java/jp/openstandia/connector/atlassian/AtlassianGuardFilter.java @@ -0,0 +1,57 @@ +/* + * Copyright Nomura Research Institute, Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package jp.openstandia.connector.atlassian; + +import org.identityconnectors.framework.common.objects.Attribute; +import org.identityconnectors.framework.common.objects.Name; +import org.identityconnectors.framework.common.objects.Uid; + +public class AtlassianGuardFilter { + final String attributeName; + final FilterType filterType; + final Attribute attributeValue; + + public AtlassianGuardFilter(String attributeName, FilterType filterType, Attribute attributeValue) { + this.attributeName = attributeName; + this.filterType = filterType; + this.attributeValue = attributeValue; + } + + public boolean isByName() { + return attributeName.equals(Name.NAME) && filterType == FilterType.EXACT_MATCH; + } + + public boolean isByUid() { + return attributeName.equals(Uid.NAME) && filterType == FilterType.EXACT_MATCH; + } + + public boolean isByMembers() { + return attributeName.equals("members.User.value") && filterType == FilterType.EXACT_MATCH; + } + + public enum FilterType { + EXACT_MATCH + } + + @Override + public String toString() { + return "AtlassianGuardFilter{" + + "attributeName='" + attributeName + '\'' + + ", filterType=" + filterType + + ", attributeValue='" + attributeValue + '\'' + + '}'; + } +} diff --git a/src/main/java/jp/openstandia/connector/atlassian/AtlassianGuardFilterTranslator.java b/src/main/java/jp/openstandia/connector/atlassian/AtlassianGuardFilterTranslator.java new file mode 100644 index 0000000..5ae74ff --- /dev/null +++ b/src/main/java/jp/openstandia/connector/atlassian/AtlassianGuardFilterTranslator.java @@ -0,0 +1,82 @@ +/* + * Copyright Nomura Research Institute, Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package jp.openstandia.connector.atlassian; + +import org.identityconnectors.common.logging.Log; +import org.identityconnectors.framework.common.objects.*; +import org.identityconnectors.framework.common.objects.filter.AbstractFilterTranslator; +import org.identityconnectors.framework.common.objects.filter.ContainsAllValuesFilter; +import org.identityconnectors.framework.common.objects.filter.EqualsFilter; + +public class AtlassianGuardFilterTranslator extends AbstractFilterTranslator { + + private static final Log LOG = Log.getLog(AtlassianGuardFilterTranslator.class); + + private final OperationOptions options; + private final ObjectClass objectClass; + + public AtlassianGuardFilterTranslator(ObjectClass objectClass, OperationOptions options) { + this.objectClass = objectClass; + this.options = options; + } + + @Override + protected AtlassianGuardFilter createEqualsExpression(EqualsFilter filter, boolean not) { + if (not) { + return null; + } + Attribute attr = filter.getAttribute(); + + if (attr instanceof Uid) { + Uid uid = (Uid) attr; + AtlassianGuardFilter uidFilter = new AtlassianGuardFilter(uid.getName(), + AtlassianGuardFilter.FilterType.EXACT_MATCH, + uid); + return uidFilter; + } + if (attr instanceof Name) { + Name name = (Name) attr; + AtlassianGuardFilter nameFilter = new AtlassianGuardFilter(name.getName(), + AtlassianGuardFilter.FilterType.EXACT_MATCH, + name); + return nameFilter; + } + + // Not supported searching by other attributes + return null; + } + + @Override + protected AtlassianGuardFilter createContainsAllValuesExpression(ContainsAllValuesFilter filter, boolean not) { + if (not) { + return null; + } + Attribute attr = filter.getAttribute(); + + // Unfortunately, Atlassian Guard doesn't support "groups" attribute in User schema. + // So IDM try to fetch the groups which the user belongs to by using ContainsAllValuesFilter. + if (objectClass.equals(AtlassianGuardGroupHandler.GROUP_OBJECT_CLASS) && + attr.getName().equals("members.User.value")) { + AtlassianGuardFilter filterGroupByMember = new AtlassianGuardFilter(attr.getName(), + AtlassianGuardFilter.FilterType.EXACT_MATCH, + attr); + return filterGroupByMember; + } + + // Not supported searching by other attributes + return null; + } +} diff --git a/src/main/java/jp/openstandia/connector/atlassian/AtlassianGuardGroupHandler.java b/src/main/java/jp/openstandia/connector/atlassian/AtlassianGuardGroupHandler.java new file mode 100644 index 0000000..010fdef --- /dev/null +++ b/src/main/java/jp/openstandia/connector/atlassian/AtlassianGuardGroupHandler.java @@ -0,0 +1,222 @@ +/* + * Copyright Nomura Research Institute, Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package jp.openstandia.connector.atlassian; + +import jp.openstandia.connector.util.ObjectHandler; +import jp.openstandia.connector.util.SchemaDefinition; +import org.identityconnectors.common.StringUtil; +import org.identityconnectors.common.logging.Log; +import org.identityconnectors.framework.common.exceptions.AlreadyExistsException; +import org.identityconnectors.framework.common.objects.*; + +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import static jp.openstandia.connector.util.Utils.toZoneDateTimeForISO8601OffsetDateTime; +import static org.identityconnectors.framework.common.objects.AttributeInfo.Flags.*; + +public class AtlassianGuardGroupHandler implements ObjectHandler { + + public static final ObjectClass GROUP_OBJECT_CLASS = new ObjectClass("Group"); + + private static final Log LOGGER = Log.getLog(AtlassianGuardGroupHandler.class); + + private final AtlassianGuardConfiguration configuration; + private final AtlassianGuardRESTClient client; + private final SchemaDefinition schema; + + public AtlassianGuardGroupHandler(AtlassianGuardConfiguration configuration, AtlassianGuardRESTClient client, + SchemaDefinition schema) { + this.configuration = configuration; + this.client = client; + this.schema = schema; + } + + public static SchemaDefinition.Builder createSchema(AtlassianGuardConfiguration configuration, AtlassianGuardRESTClient client) { + SchemaDefinition.Builder sb + = SchemaDefinition.newBuilder(GROUP_OBJECT_CLASS, AtlassianGuardGroupModel.class, PatchOperationsModel.class, AtlassianGuardGroupModel.class); + + // __UID__ + // The id for the group. Must be unique and unchangeable. + sb.addUid("groupId", + SchemaDefinition.Types.STRING_CASE_IGNORE, + null, + (source) -> source.id, + "id", + NOT_CREATABLE, NOT_UPDATEABLE + ); + + // displayName (__NAME__) + // The name for the group. Must be unique for ConnId Name attribute though it's not required and unique in Atlassian Guard. + // Also, it's case-insensitive. + sb.addName("displayName", + SchemaDefinition.Types.STRING_CASE_IGNORE, + (source, dest) -> dest.displayName = source, + (source, dest) -> dest.replace("displayName", source), + (source) -> StringUtil.isEmpty(source.displayName) ? source.id : source.displayName, + null, + REQUIRED + ); + + // Attributes + // Nothing + + // Association + sb.addAsMultiple("members.User.value", + SchemaDefinition.Types.UUID, + (source, dest) -> { + dest.members = source != null ? source.stream().map(x -> { + AtlassianGuardGroupModel.Member member = new AtlassianGuardGroupModel.Member(); + member.value = x; + return member; + }).collect(Collectors.toList()) : null; + }, + (add, dest) -> dest.addMembers(add), + (remove, dest) -> dest.removeMembers(remove), + (source) -> source.members != null ? source.members.stream().filter(x -> x.type != null && x.type.equals("User")).map(x -> x.value) : null, + null + ); + + // Metadata (readonly) + sb.add("meta.created", + SchemaDefinition.Types.DATETIME, + null, + (source) -> source.meta == null || source.meta.created == null ? null : toZoneDateTimeForISO8601OffsetDateTime(source.meta.created), + null, + NOT_CREATABLE, NOT_UPDATEABLE + ); + sb.add("meta.lastModified", + SchemaDefinition.Types.DATETIME, + null, + (source) -> source.meta == null || source.meta.lastModified == null ? null : toZoneDateTimeForISO8601OffsetDateTime(source.meta.lastModified), + null, + NOT_CREATABLE, NOT_UPDATEABLE + ); + LOGGER.ok("The constructed group schema"); + + return sb; + } + + private static Stream filterGroups(AtlassianGuardConfiguration configuration, Stream groups) { + Set ignoreGroup = configuration.getIgnoreGroupSet(); + return groups.filter(g -> !ignoreGroup.contains(g)); + } + + @Override + public SchemaDefinition getSchema() { + return schema; + } + + @Override + public Uid create(Set attributes) { + AtlassianGuardGroupModel group = new AtlassianGuardGroupModel(); + AtlassianGuardGroupModel mapped = schema.apply(attributes, group); + + if (configuration.isUniqueCheckGroupDisplayNameEnabled()) { + OperationOptions options = new OperationOptionsBuilder().setPageSize(1).setPagedResultsOffset(1).build(); + AtlassianGuardGroupModel found = client.getGroup(new Name(group.displayName), options, Collections.emptySet()); + if (found != null && found.displayName.equalsIgnoreCase(group.displayName)) { + throw new AlreadyExistsException(String.format("Group \"%s\" already exists", group.displayName)); + } + } + + Uid newUid = client.createGroup(mapped); + + return newUid; + } + + @Override + public Set updateDelta(Uid uid, Set modifications, OperationOptions options) { + PatchOperationsModel dest = new PatchOperationsModel(); + + schema.applyDelta(modifications, dest); + + if (dest.hasAttributesChange()) { + client.patchGroup(uid, dest); + } + + return null; + } + + @Override + public void delete(Uid uid, OperationOptions options) { + client.deleteGroup(uid); + } + + @Override + public int getByUid(Uid uid, ResultsHandler resultsHandler, OperationOptions options, + Set returnAttributesSet, Set fetchFieldsSet, + boolean allowPartialAttributeValues, int pageSize, int pageOffset) { + AtlassianGuardGroupModel group = client.getGroup(uid, options, fetchFieldsSet); + + if (group != null) { + resultsHandler.handle(toConnectorObject(schema, group, returnAttributesSet, allowPartialAttributeValues)); + return 1; + } + return 0; + } + + @Override + public int getByName(Name name, ResultsHandler resultsHandler, OperationOptions options, + Set returnAttributesSet, Set fetchFieldsSet, + boolean allowPartialAttributeValues, int pageSize, int pageOffset) { + AtlassianGuardGroupModel group = client.getGroup(name, options, fetchFieldsSet); + + if (group != null) { + resultsHandler.handle(toConnectorObject(schema, group, returnAttributesSet, allowPartialAttributeValues)); + return 1; + } + return 0; + } + + @Override + public int getByMembers(Attribute attribute, ResultsHandler resultsHandler, OperationOptions options, + Set returnAttributesSet, Set fetchFieldSet, boolean allowPartialAttributeValues, int pageSize, int pageOffset) { + // Unfortunately, Atlassian Guard doesn't support filter by member (It supports displayName filter only). + // So, we need to fetch all groups. + Set memberIds = new HashSet<>(attribute.getValue()); + return client.getGroups((g) -> { + // Ignored group + Set ignoreGroupSet = configuration.getIgnoreGroupSet(); + // displayName is case-insensitive + if (ignoreGroupSet.contains(g.displayName.toLowerCase())) { + return true; + } + + // Filter by member's value + boolean contains = g.members.stream() + .map(m -> m.value) + .collect(Collectors.toSet()) + .containsAll(memberIds); + if (contains) { + return resultsHandler.handle(toConnectorObject(schema, g, returnAttributesSet, allowPartialAttributeValues)); + } + + return true; + }, options, fetchFieldSet, pageSize, pageOffset); + } + + @Override + public int getAll(ResultsHandler resultsHandler, OperationOptions options, + Set returnAttributesSet, Set fetchFieldsSet, + boolean allowPartialAttributeValues, int pageSize, int pageOffset) { + return client.getGroups((g) -> resultsHandler.handle(toConnectorObject(schema, g, returnAttributesSet, allowPartialAttributeValues)), + options, fetchFieldsSet, pageSize, pageOffset); + } +} diff --git a/src/main/java/jp/openstandia/connector/atlassian/AtlassianGuardGroupModel.java b/src/main/java/jp/openstandia/connector/atlassian/AtlassianGuardGroupModel.java new file mode 100644 index 0000000..46a2021 --- /dev/null +++ b/src/main/java/jp/openstandia/connector/atlassian/AtlassianGuardGroupModel.java @@ -0,0 +1,64 @@ +/* + * Copyright Nomura Research Institute, Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package jp.openstandia.connector.atlassian; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +@JsonIgnoreProperties(ignoreUnknown = true) +@JsonInclude(JsonInclude.Include.NON_NULL) +public class AtlassianGuardGroupModel { + private static final String GROUP = "urn:ietf:params:scim:schemas:core:2.0:Group"; + + public List schemas = Collections.singletonList(GROUP); + public String id; // auto generated + public String displayName; + public List members; + public Meta meta; + + public void addMembers(List source) { + if (members == null) { + members = new ArrayList<>(); + } + for (String s : source) { + Member member = new Member(); + member.value = s; + members.add(member); + } + } + + @JsonIgnoreProperties(ignoreUnknown = true) + @JsonInclude(JsonInclude.Include.NON_NULL) + public static class Member { + public String value; + @JsonProperty("$ref") + public String ref; + public String type; + } + + @JsonIgnoreProperties(ignoreUnknown = true) + public static class Meta { + public String resourceType; + public String created; + public String lastModified; + public String location; + } +} diff --git a/src/main/java/jp/openstandia/connector/atlassian/AtlassianGuardRESTClient.java b/src/main/java/jp/openstandia/connector/atlassian/AtlassianGuardRESTClient.java new file mode 100644 index 0000000..2258f3f --- /dev/null +++ b/src/main/java/jp/openstandia/connector/atlassian/AtlassianGuardRESTClient.java @@ -0,0 +1,314 @@ +/* + * Copyright Nomura Research Institute, Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package jp.openstandia.connector.atlassian; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import jp.openstandia.connector.util.AbstractRESTClient; +import jp.openstandia.connector.util.QueryHandler; +import okhttp3.OkHttpClient; +import okhttp3.Response; +import org.identityconnectors.common.logging.Log; +import org.identityconnectors.framework.common.exceptions.AlreadyExistsException; +import org.identityconnectors.framework.common.exceptions.ConnectionFailedException; +import org.identityconnectors.framework.common.exceptions.ConnectorIOException; +import org.identityconnectors.framework.common.exceptions.UnknownUidException; +import org.identityconnectors.framework.common.objects.Name; +import org.identityconnectors.framework.common.objects.OperationOptions; +import org.identityconnectors.framework.common.objects.Uid; + +import java.io.IOException; +import java.util.*; + +import static jp.openstandia.connector.atlassian.AtlassianGuardGroupHandler.GROUP_OBJECT_CLASS; +import static jp.openstandia.connector.atlassian.AtlassianGuardUserHandler.USER_OBJECT_CLASS; + +public class AtlassianGuardRESTClient extends AbstractRESTClient { + private static final Log LOG = Log.getLog(AtlassianGuardRESTClient.class); + private ErrorHandler ERROR_HANDLER = new AtlassianGuardErrorHandler(); + + private String testEndpoint; + private String userEndpoint; + private String groupEndpoint; + + @JsonIgnoreProperties(ignoreUnknown = true) + static class UserListBody { + public int totalResults; + public int startIndex; + public int itemPerPage; + @JsonProperty("Resources") + public List resources; + } + + @JsonIgnoreProperties(ignoreUnknown = true) + static class GroupListBody { + public int totalResults; + public int startIndex; + public int itemPerPage; + @JsonProperty("Resources") + public List resources; + } + + static class AtlassianGuardErrorHandler implements ErrorHandler { + @Override + public boolean inNotAuthenticated(Response response) { + // {"schemas":["urn:ietf:params:scim:api:messages:2.0:Error"],"status":"401","detail":"UnauthorizedError: Invalid authentication"} + return response.code() == 401; + } + + @Override + public boolean isAlreadyExists(Response response) { + // {"schemas":["urn:ietf:params:scim:api:messages:2.0:Error"],"status":"409","scimType":"uniqueness"} + return response.code() == 409; + } + + @Override + public boolean isInvalidRequest(Response response) { + // {"schemas":["urn:ietf:params:scim:api:messages:2.0:Error"],"status":"400","scimType":"invalidSyntax","detail":"body failed validation: body.startIndex should be ≥ `1` or `undefined`, instead was `0`."} + return response.code() == 400; + } + + @Override + public boolean isNotFound(Response response) { + // {"schemas":["urn:ietf:params:scim:api:messages:2.0:Error"],"status":"404"} + return response.code() == 404; + } + + @Override + public boolean isOk(Response response) { + return response.code() == 200 || response.code() == 201 || response.code() == 204; + } + + @Override + public boolean isServerError(Response response) { + return response.code() >= 500 && response.code() <= 599; + } + } + + public void init(String instanceName, AtlassianGuardConfiguration configuration, OkHttpClient httpClient) { + super.init(instanceName, configuration, httpClient, ERROR_HANDLER, false, "startIndex", "count"); + this.testEndpoint = configuration.getBaseURL() + "/ServiceProviderConfig"; + this.userEndpoint = configuration.getBaseURL() + "/Users"; + this.groupEndpoint = configuration.getBaseURL() + "/Groups"; + } + + public void test() { + try (Response response = get(testEndpoint)) { + if (response.code() != 200) { + // Something wrong.. + String body = response.body().string(); + throw new ConnectionFailedException(String.format("Failed %s test response. statusCode: %s, body: %s", + instanceName, + response.code(), + body)); + } + + LOG.info("{0} connector's connection test is OK", instanceName); + + } catch (IOException e) { + throw new ConnectionFailedException(String.format("Cannot connect to %s REST API", instanceName), e); + } + } + + // User + + public Uid createUser(AtlassianGuardUserModel newUser) throws AlreadyExistsException { + AtlassianGuardUserModel created = callCreate(USER_OBJECT_CLASS, userEndpoint, newUser, newUser.userName, (response) -> { + try { + return MAPPER.readValue(response.body().byteStream(), AtlassianGuardUserModel.class); + } catch (IOException e) { + throw new ConnectorIOException(String.format("Cannot parse %s REST API Response", instanceName), e); + } + }); + return new Uid(created.id, created.userName); + } + + public AtlassianGuardUserModel getUser(Uid uid, OperationOptions options, Set fetchFieldsSet) throws UnknownUidException { + try (Response response = callRead(USER_OBJECT_CLASS, userEndpoint, uid)) { + if (response == null) { + return null; + } + AtlassianGuardUserModel user = MAPPER.readValue(response.body().byteStream(), AtlassianGuardUserModel.class); + return user; + + } catch (IOException e) { + throw new ConnectorIOException(String.format("Cannot parse %s REST API Response", instanceName), e); + } + } + + public AtlassianGuardUserModel getUser(Name name, OperationOptions options, Set fetchFieldsSet) throws UnknownUidException { + Map params = new HashMap<>(); + params.put("filter", formatFilter("userName eq \"%s\"", name.getNameValue())); + + try (Response response = callSearch(USER_OBJECT_CLASS, userEndpoint, params)) { + UserListBody list = MAPPER.readValue(response.body().byteStream(), UserListBody.class); + if (list.resources == null || list.resources.size() != 1) { + LOG.info("The {0} user is not found. userName={1}", instanceName, name.getNameValue()); + return null; + } + return list.resources.get(0); + + } catch (IOException e) { + throw new ConnectorIOException(String.format("Cannot parse %s REST API Response", instanceName), e); + } + } + + private String formatFilter(String filter, String... values) { + Object[] escaped = Arrays.stream(values) + .map(v -> v.replace("\"", "\\\"")) + .toArray(); + return String.format(filter, escaped); + } + + public void patchUser(Uid uid, PatchOperationsModel operations) { + callPatch(USER_OBJECT_CLASS, userEndpoint + "/" + uid.getUidValue(), uid, operations); + } + + public void deleteUser(Uid uid) { + callDelete(USER_OBJECT_CLASS, userEndpoint + "/" + uid.getUidValue(), uid, null); + } + + public int getUsers(QueryHandler handler, OperationOptions options, Set fetchFieldsSet, int pageSize, int pageOffset) { + // ConnId starts from 1, 0 means no offset (requested all data) + if (pageOffset < 1) { + return getAll(handler, pageSize, (start, size) -> { + Map params = new HashMap<>(); + params.put(offsetKey, String.valueOf(start)); + params.put(countKey, String.valueOf(size)); + + try (Response response = callSearch(USER_OBJECT_CLASS, userEndpoint, params)) { + UserListBody list = MAPPER.readValue(response.body().byteStream(), UserListBody.class); + return list.resources; + + } catch (IOException e) { + throw new ConnectorIOException(String.format("Cannot parse %s REST API Response", instanceName), e); + } + }); + } + + // Pagination + // Notion(SCIM v2.0) starts from 1 + int start = resolveOffset(pageOffset); + + Map params = new HashMap<>(); + params.put(offsetKey, String.valueOf(start)); + params.put(countKey, String.valueOf(pageSize)); + + try (Response response = callSearch(USER_OBJECT_CLASS, userEndpoint, params)) { + UserListBody list = MAPPER.readValue(response.body().byteStream(), UserListBody.class); + for (AtlassianGuardUserModel user : list.resources) { + if (!handler.handle(user)) { + break; + } + } + return list.totalResults; + + } catch (IOException e) { + throw new ConnectorIOException(String.format("Cannot parse %s REST API Response", instanceName), e); + } + } + + // Group + + public Uid createGroup(AtlassianGuardGroupModel newGroup) throws AlreadyExistsException { + AtlassianGuardGroupModel created = callCreate(GROUP_OBJECT_CLASS, groupEndpoint, newGroup, newGroup.displayName, (response) -> { + try { + return MAPPER.readValue(response.body().byteStream(), AtlassianGuardGroupModel.class); + } catch (IOException e) { + throw new ConnectorIOException(String.format("Cannot parse %s REST API Response", instanceName), e); + } + }); + + return new Uid(created.id, newGroup.displayName); + } + + public void patchGroup(Uid uid, PatchOperationsModel operations) { + callPatch(GROUP_OBJECT_CLASS, groupEndpoint + "/" + uid.getUidValue(), uid, operations); + } + + public AtlassianGuardGroupModel getGroup(Uid uid, OperationOptions options, Set fetchFieldsSet) throws UnknownUidException { + try (Response response = callRead(GROUP_OBJECT_CLASS, groupEndpoint, uid)) { + if (response == null) { + return null; + } + AtlassianGuardGroupModel group = MAPPER.readValue(response.body().byteStream(), AtlassianGuardGroupModel.class); + return group; + + } catch (IOException e) { + throw new ConnectorIOException(String.format("Cannot parse %s REST API Response", instanceName), e); + } + } + + public AtlassianGuardGroupModel getGroup(Name name, OperationOptions options, Set fetchFieldsSet) { + Map params = new HashMap<>(); + params.put("filter", formatFilter("displayName eq \"%s\"", name.getNameValue())); + + try (Response response = callSearch(GROUP_OBJECT_CLASS, groupEndpoint, params)) { + GroupListBody list = MAPPER.readValue(response.body().byteStream(), GroupListBody.class); + if (list.resources == null || list.resources.size() != 1) { + LOG.info("The {0} group is not found. displayName={1}", instanceName, name.getNameValue()); + return null; + } + return list.resources.get(0); + + } catch (IOException e) { + throw new ConnectorIOException(String.format("Cannot parse %s REST API Response", instanceName), e); + } + } + + public int getGroups(QueryHandler handler, OperationOptions options, Set fetchFieldsSet, int pageSize, int pageOffset) { + // ConnId starts from 1, 0 means no offset (requested all data) + if (pageOffset < 1) { + return getAll(handler, pageSize, (start, size) -> { + Map params = new HashMap<>(); + params.put(offsetKey, String.valueOf(start)); + params.put(countKey, String.valueOf(size)); + + try (Response response = callSearch(GROUP_OBJECT_CLASS, groupEndpoint, params)) { + GroupListBody list = MAPPER.readValue(response.body().byteStream(), GroupListBody.class); + return list.resources; + + } catch (IOException e) { + throw new ConnectorIOException(String.format("Cannot parse %s REST API Response", instanceName), e); + } + }); + } + + // Pagination + int start = resolveOffset(pageOffset); + + Map params = new HashMap<>(); + params.put(offsetKey, String.valueOf(start)); + params.put(countKey, String.valueOf(pageSize)); + + try (Response response = callSearch(GROUP_OBJECT_CLASS, groupEndpoint, params)) { + GroupListBody list = MAPPER.readValue(response.body().byteStream(), GroupListBody.class); + for (AtlassianGuardGroupModel group : list.resources) { + if (!handler.handle(group)) { + break; + } + } + return list.totalResults; + + } catch (IOException e) { + throw new ConnectorIOException(String.format("Cannot parse %s REST API Response", instanceName), e); + } + } + + public void deleteGroup(Uid uid) { + callDelete(GROUP_OBJECT_CLASS, groupEndpoint + "/" + uid.getUidValue(), uid, null); + } +} diff --git a/src/main/java/jp/openstandia/connector/atlassian/AtlassianGuardSchema.java b/src/main/java/jp/openstandia/connector/atlassian/AtlassianGuardSchema.java new file mode 100644 index 0000000..380bc56 --- /dev/null +++ b/src/main/java/jp/openstandia/connector/atlassian/AtlassianGuardSchema.java @@ -0,0 +1,75 @@ +/* + * Copyright Nomura Research Institute, Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package jp.openstandia.connector.atlassian; + +import jp.openstandia.connector.util.ObjectHandler; +import jp.openstandia.connector.util.SchemaDefinition; +import org.identityconnectors.framework.common.objects.ObjectClass; +import org.identityconnectors.framework.common.objects.OperationOptionInfoBuilder; +import org.identityconnectors.framework.common.objects.Schema; +import org.identityconnectors.framework.common.objects.SchemaBuilder; +import org.identityconnectors.framework.spi.operations.SearchOp; + +import java.util.HashMap; +import java.util.Map; +import java.util.function.Function; + +/** + * Schema for Atlassian Guard objects. + * + * @author Hiroyuki Wada + */ +public class AtlassianGuardSchema { + + private final AtlassianGuardConfiguration configuration; + private final AtlassianGuardRESTClient client; + + public final Schema schema; + + private Map schemaHandlerMap; + + public AtlassianGuardSchema(AtlassianGuardConfiguration configuration, AtlassianGuardRESTClient client) { + this.configuration = configuration; + this.client = client; + this.schemaHandlerMap = new HashMap<>(); + + SchemaBuilder schemaBuilder = new SchemaBuilder(AtlassianGuardConnector.class); + + buildSchema(schemaBuilder, AtlassianGuardUserHandler.createSchema(configuration, client).build(), + (schema) -> new AtlassianGuardUserHandler(configuration, client, schema)); + buildSchema(schemaBuilder, AtlassianGuardGroupHandler.createSchema(configuration, client).build(), + (schema) -> new AtlassianGuardGroupHandler(configuration, client, schema)); + + // Define operation options + schemaBuilder.defineOperationOption(OperationOptionInfoBuilder.buildAttributesToGet(), SearchOp.class); + schemaBuilder.defineOperationOption(OperationOptionInfoBuilder.buildReturnDefaultAttributes(), SearchOp.class); + schemaBuilder.defineOperationOption(OperationOptionInfoBuilder.buildPageSize(), SearchOp.class); + schemaBuilder.defineOperationOption(OperationOptionInfoBuilder.buildPagedResultsOffset(), SearchOp.class); + + this.schema = schemaBuilder.build(); + + } + + private void buildSchema(SchemaBuilder builder, SchemaDefinition schemaDefinition, Function callback) { + builder.defineObjectClass(schemaDefinition.getObjectClassInfo()); + ObjectHandler handler = callback.apply(schemaDefinition); + this.schemaHandlerMap.put(schemaDefinition.getType(), handler); + } + + public ObjectHandler getSchemaHandler(ObjectClass objectClass) { + return schemaHandlerMap.get(objectClass.getObjectClassValue()); + } +} \ No newline at end of file diff --git a/src/main/java/jp/openstandia/connector/atlassian/AtlassianGuardUserHandler.java b/src/main/java/jp/openstandia/connector/atlassian/AtlassianGuardUserHandler.java new file mode 100644 index 0000000..2421496 --- /dev/null +++ b/src/main/java/jp/openstandia/connector/atlassian/AtlassianGuardUserHandler.java @@ -0,0 +1,400 @@ +/* + * Copyright Nomura Research Institute, Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package jp.openstandia.connector.atlassian; + +import jp.openstandia.connector.util.ObjectHandler; +import jp.openstandia.connector.util.SchemaDefinition; +import org.identityconnectors.common.logging.Log; +import org.identityconnectors.framework.common.exceptions.InvalidAttributeValueException; +import org.identityconnectors.framework.common.objects.*; + +import java.util.ArrayList; +import java.util.Set; + +import static jp.openstandia.connector.util.Utils.toZoneDateTimeForISO8601OffsetDateTime; +import static org.identityconnectors.framework.common.objects.AttributeInfo.Flags.*; + +public class AtlassianGuardUserHandler implements ObjectHandler { + + public static final ObjectClass USER_OBJECT_CLASS = new ObjectClass("User"); + + private static final Log LOGGER = Log.getLog(AtlassianGuardUserHandler.class); + + protected final AtlassianGuardConfiguration configuration; + protected final AtlassianGuardRESTClient client; + protected final SchemaDefinition schema; + + public AtlassianGuardUserHandler(AtlassianGuardConfiguration configuration, AtlassianGuardRESTClient client, + SchemaDefinition schema) { + this.configuration = configuration; + this.client = client; + this.schema = schema; + } + + public static SchemaDefinition.Builder createSchema(AtlassianGuardConfiguration configuration, AtlassianGuardRESTClient client) { + SchemaDefinition.Builder sb + = SchemaDefinition.newBuilder(USER_OBJECT_CLASS, AtlassianGuardUserModel.class, PatchOperationsModel.class, AtlassianGuardUserModel.class); + + return createSchema(sb); + } + + public static SchemaDefinition.Builder createSchema(SchemaDefinition.Builder sb) { + // Atlassian Guard supports SCIM v2.0. + // https://support.atlassian.com/provisioning-users/docs/understand-user-provisioning/ + + // __UID__ + // The id for the user. Must be unique and unchangeable. + sb.addUid("userId", + SchemaDefinition.Types.STRING_CASE_IGNORE, + null, + (source) -> source.id, + "id", + NOT_CREATABLE, NOT_UPDATEABLE + ); + + // code (__NAME__) + // The name for the user. Must be unique and changeable. + // Also, it's case-sensitive. + sb.addName("userName", + SchemaDefinition.Types.STRING, + (source, dest) -> dest.userName = source, + (source, dest) -> dest.replace("userName", source), + (source) -> source.userName, + null, + REQUIRED + ); + + // __ENABLE__ attribute + sb.add(OperationalAttributes.ENABLE_NAME, + SchemaDefinition.Types.BOOLEAN, + (source, dest) -> dest.active = source, + (source, dest) -> dest.replace("active", source), + (source) -> source.active, + "active" + ); + + // Attributes + sb.add("name.formatted", + SchemaDefinition.Types.STRING, + (source, dest) -> { + if (dest.name == null) { + dest.name = new AtlassianGuardUserModel.Name(); + } + dest.name.formatted = source; + }, + (source, dest) -> dest.replace("name.formatted", source), + (source) -> source.name != null ? source.name.formatted : null, + null + ); + sb.add("name.familyName", + SchemaDefinition.Types.STRING, + (source, dest) -> { + if (dest.name == null) { + dest.name = new AtlassianGuardUserModel.Name(); + } + dest.name.familyName = source; + }, + (source, dest) -> dest.replace("name.familyName", source), + (source) -> source.name != null ? source.name.familyName : null, + null + ); + sb.add("name.givenName", + SchemaDefinition.Types.STRING, + (source, dest) -> { + if (dest.name == null) { + dest.name = new AtlassianGuardUserModel.Name(); + } + dest.name.givenName = source; + }, + (source, dest) -> dest.replace("name.givenName", source), + (source) -> source.name != null ? source.name.givenName : null, + null + ); + sb.add("name.middleName", + SchemaDefinition.Types.STRING, + (source, dest) -> { + if (dest.name == null) { + dest.name = new AtlassianGuardUserModel.Name(); + } + dest.name.middleName = source; + }, + (source, dest) -> dest.replace("name.middleName", source), + (source) -> source.name != null ? source.name.middleName : null, + null + ); + sb.add("name.honorificPrefix", + SchemaDefinition.Types.STRING, + (source, dest) -> { + if (dest.name == null) { + dest.name = new AtlassianGuardUserModel.Name(); + } + dest.name.honorificPrefix = source; + }, + (source, dest) -> dest.replace("name.honorificPrefix", source), + (source) -> source.name != null ? source.name.honorificPrefix : null, + null + ); + sb.add("name.honorificSuffix", + SchemaDefinition.Types.STRING, + (source, dest) -> { + if (dest.name == null) { + dest.name = new AtlassianGuardUserModel.Name(); + } + dest.name.honorificSuffix = source; + }, + (source, dest) -> dest.replace("name.honorificSuffix", source), + (source) -> source.name != null ? source.name.honorificSuffix : null, + null + ); + sb.add("displayName", + SchemaDefinition.Types.STRING, + (source, dest) -> { + dest.displayName = source; + }, + (source, dest) -> dest.replace("displayName", source), + (source) -> source.displayName, + null + ); + sb.add("nickName", + SchemaDefinition.Types.STRING, + (source, dest) -> { + dest.nickName = source; + }, + (source, dest) -> dest.replace("nickName", source), + (source) -> source.nickName, + null + ); + sb.add("title", + SchemaDefinition.Types.STRING, + (source, dest) -> { + dest.title = source; + }, + (source, dest) -> dest.replace("title", source), + (source) -> source.title, + null + ); + sb.add("preferredLanguage", + SchemaDefinition.Types.STRING, + (source, dest) -> { + dest.preferredLanguage = source; + }, + (source, dest) -> dest.replace("preferredLanguage", source), + (source) -> source.preferredLanguage, + null + ); + sb.add("timezone", + SchemaDefinition.Types.STRING, + (source, dest) -> { + dest.timezone = source; + }, + (source, dest) -> dest.replace("timezone", source), + (source) -> source.timezone, + null + ); + sb.add("active", + SchemaDefinition.Types.BOOLEAN, + (source, dest) -> { + dest.active = source; + }, + (source, dest) -> dest.replace("active", source), + (source) -> source.active, + null + ); + sb.add("primaryEmail", + SchemaDefinition.Types.STRING_CASE_IGNORE, + (source, dest) -> { + if (source == null) { + return; + } + AtlassianGuardUserModel.Email email = new AtlassianGuardUserModel.Email(); + email.value = source; + email.primary = true; + + dest.emails = new ArrayList<>(); + dest.emails.add(email); + }, + (source, dest) -> { + if (source == null) { + dest.replace((AtlassianGuardUserModel.Email) null); + return; + } + AtlassianGuardUserModel.Email newEmail = new AtlassianGuardUserModel.Email(); + newEmail.value = source; + newEmail.primary = true; + dest.replace(newEmail); + }, + (source) -> { + if (source.emails == null || source.emails.isEmpty()) { + return null; + } + return source.emails.stream() + .filter(x -> x.primary) + .findFirst() + .map(x -> x.value) + .orElse(null); + }, + "emails" + ); + sb.add("primaryPhoneNumber", + SchemaDefinition.Types.STRING, + (source, dest) -> { + if (source == null) { + return; + } + + String[] split = source.split("/"); + if (split.length != 2) { + throw new InvalidAttributeValueException("Invalid primaryPhoneNumber: " + source); + } + + AtlassianGuardUserModel.PhoneNumber phoneNumber = new AtlassianGuardUserModel.PhoneNumber(); + phoneNumber.value = split[0]; + phoneNumber.primary = true; + phoneNumber.type = split[1]; + + dest.phoneNumbers = new ArrayList<>(); + dest.phoneNumbers.add(phoneNumber); + }, + (source, dest) -> { + if (source == null) { + dest.replace((AtlassianGuardUserModel.PhoneNumber) null); + return; + } + + String[] split = source.split("/"); + if (split.length != 2) { + throw new InvalidAttributeValueException("Invalid primaryPhoneNumber: " + source); + } + + AtlassianGuardUserModel.PhoneNumber newPhoneNumber = new AtlassianGuardUserModel.PhoneNumber(); + newPhoneNumber.value = split[0]; + newPhoneNumber.primary = true; + newPhoneNumber.type = split[1]; + dest.replace(newPhoneNumber); + }, + (source) -> { + if (source.phoneNumbers == null || source.phoneNumbers.isEmpty()) { + return null; + } + return source.phoneNumbers.stream() + .filter(x -> x.primary) + .findFirst() + .map(x -> x.value + "/" + x.type) + .orElse(null); + }, + "phoneNumbers" + ); + + // Association + // Atlassian Guard supports "groups" attributes + sb.addAsMultiple("groups", + SchemaDefinition.Types.UUID, + null, + null, + null, + (source) -> source.groups != null ? source.groups.stream().filter(x -> x.type != null && x.type.equals("Group")).map(x -> x.value) : null, + null, + NOT_CREATABLE, NOT_UPDATEABLE + ); + + // Metadata (readonly) + sb.add("meta.created", + SchemaDefinition.Types.DATETIME, + null, + (source) -> toZoneDateTimeForISO8601OffsetDateTime(source.meta.created), + null, + NOT_CREATABLE, NOT_UPDATEABLE + ); + sb.add("meta.lastModified", + SchemaDefinition.Types.DATETIME, + null, + (source) -> toZoneDateTimeForISO8601OffsetDateTime(source.meta.lastModified), + null, + NOT_CREATABLE, NOT_UPDATEABLE + ); + + LOGGER.ok("The constructed user schema"); + + return sb; + } + + @Override + public SchemaDefinition getSchema() { + return schema; + } + + @Override + public Uid create(Set attributes) { + AtlassianGuardUserModel user = new AtlassianGuardUserModel(); + AtlassianGuardUserModel mapped = schema.apply(attributes, user); + + Uid newUid = client.createUser(mapped); + + return newUid; + } + + @Override + public Set updateDelta(Uid uid, Set modifications, OperationOptions options) { + PatchOperationsModel dest = new PatchOperationsModel(); + + schema.applyDelta(modifications, dest); + + if (dest.hasAttributesChange()) { + client.patchUser(uid, dest); + } + + return null; + } + + @Override + public void delete(Uid uid, OperationOptions options) { + client.deleteUser(uid); + } + + @Override + public int getByUid(Uid uid, ResultsHandler resultsHandler, OperationOptions options, + Set returnAttributesSet, Set fetchFieldsSet, + boolean allowPartialAttributeValues, int pageSize, int pageOffset) { + AtlassianGuardUserModel user = client.getUser(uid, options, fetchFieldsSet); + + if (user != null) { + resultsHandler.handle(toConnectorObject(schema, user, returnAttributesSet, allowPartialAttributeValues)); + return 1; + } + return 0; + } + + @Override + public int getByName(Name name, ResultsHandler resultsHandler, OperationOptions options, + Set returnAttributesSet, Set fetchFieldsSet, + boolean allowPartialAttributeValues, int pageSize, int pageOffset) { + AtlassianGuardUserModel user = client.getUser(name, options, fetchFieldsSet); + + if (user != null) { + resultsHandler.handle(toConnectorObject(schema, user, returnAttributesSet, allowPartialAttributeValues)); + return 1; + } + return 0; + } + + @Override + public int getAll(ResultsHandler resultsHandler, OperationOptions options, + Set returnAttributesSet, Set fetchFieldsSet, + boolean allowPartialAttributeValues, int pageSize, int pageOffset) { + return client.getUsers((u) -> resultsHandler.handle(toConnectorObject(schema, u, returnAttributesSet, allowPartialAttributeValues)), + options, fetchFieldsSet, pageSize, pageOffset); + } +} diff --git a/src/main/java/jp/openstandia/connector/atlassian/AtlassianGuardUserModel.java b/src/main/java/jp/openstandia/connector/atlassian/AtlassianGuardUserModel.java new file mode 100644 index 0000000..35a6b5d --- /dev/null +++ b/src/main/java/jp/openstandia/connector/atlassian/AtlassianGuardUserModel.java @@ -0,0 +1,92 @@ +/* + * Copyright Nomura Research Institute, Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package jp.openstandia.connector.atlassian; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.Collections; +import java.util.List; + +@JsonIgnoreProperties(ignoreUnknown = true) +@JsonInclude(JsonInclude.Include.NON_NULL) +public class AtlassianGuardUserModel { + protected static final String USER = "urn:ietf:params:scim:schemas:core:2.0:User"; + + public List schemas = Collections.singletonList(USER); + public String id; // auto generated + public String userName; + public Name name; + public String displayName; + public String nickName; + public String title; + public String preferredLanguage; + public String timezone; + public Boolean active; + public List emails; + public List phoneNumbers; + public List groups; + public Meta meta; + + @JsonIgnoreProperties(ignoreUnknown = true) + @JsonInclude(JsonInclude.Include.NON_NULL) + public static class Name { + public String formatted; + public String familyName; + public String givenName; + public String middleName; + public String honorificPrefix; + public String honorificSuffix; + } + + @JsonIgnoreProperties(ignoreUnknown = true) + @JsonInclude(JsonInclude.Include.NON_NULL) + public static class Email { + public String value; + public String display; + public String type; + public Boolean primary; + } + + @JsonIgnoreProperties(ignoreUnknown = true) + @JsonInclude(JsonInclude.Include.NON_NULL) + public static class PhoneNumber { + public String value; + public String display; + public String type; + public Boolean primary; + } + + @JsonIgnoreProperties(ignoreUnknown = true) + @JsonInclude(JsonInclude.Include.NON_NULL) + public static class Group { + public String value; + @JsonProperty("$ref") + public String ref; + public String display; + public String type; + public Boolean primary; + } + + @JsonIgnoreProperties(ignoreUnknown = true) + public static class Meta { + public String resourceType; + public String created; + public String lastModified; + public String location; + } +} diff --git a/src/main/java/jp/openstandia/connector/atlassian/PatchOperationsModel.java b/src/main/java/jp/openstandia/connector/atlassian/PatchOperationsModel.java new file mode 100644 index 0000000..6221058 --- /dev/null +++ b/src/main/java/jp/openstandia/connector/atlassian/PatchOperationsModel.java @@ -0,0 +1,122 @@ +package jp.openstandia.connector.atlassian; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; + +@JsonIgnoreProperties(ignoreUnknown = true) +public class PatchOperationsModel { + private static final String PATCH_OP = "urn:ietf:params:scim:api:messages:2.0:PatchOp"; + + public List schemas = Collections.singletonList(PATCH_OP); + + @JsonProperty("Operations") + public List operations = new ArrayList<>(); + + public void replace(String path, String value) { + Operation op = new Operation(); + op.op = "replace"; + op.path = path; + op.value = value == null ? "" : value; + operations.add(op); + } + + public void replace(String path, Boolean value) { + if (value == null) { + return; + } + Operation op = new Operation(); + op.op = "replace"; + op.path = path; + op.value = value; + operations.add(op); + } + + public void addMembers(List values) { + List members = values.stream().map(v -> { + Member member = new Member(); + member.value = v; + return member; + }).collect(Collectors.toList()); + + Operation op = new Operation(); + op.op = "add"; + op.path = "members"; + op.value = members; + + operations.add(op); + } + + public void removeMembers(List values) { + List members = values.stream().map(v -> { + Member member = new Member(); + member.value = v; + return member; + }).collect(Collectors.toList()); + + Operation op = new Operation(); + op.op = "remove"; + op.path = "members"; + op.value = members; + + operations.add(op); + } + + public void replace(AtlassianGuardUserModel.Email value) { + if (value == null) { + operations.add(removeAllOp("emails")); + return; + } + + List emails = new ArrayList<>(); + emails.add(value); + + Operation op = new Operation(); + op.op = "replace"; + op.path = "emails"; + op.value = emails; + operations.add(op); + } + + public void replace(AtlassianGuardUserModel.PhoneNumber value) { + if (value == null) { + operations.add(removeAllOp("phoneNumbers")); + return; + } + + List phoneNumbers = new ArrayList<>(); + phoneNumbers.add(value); + + Operation op = new Operation(); + op.op = "replace"; + op.path = "phoneNumbers"; + op.value = phoneNumbers; + operations.add(op); + } + + private Operation removeAllOp(String path) { + Operation op = new Operation(); + op.op = "replace"; + op.path = path; + op.value = Collections.emptyList(); + return op; + } + + public static class Operation { + public String op; + public String path; + public Object value; + } + + public static class Member { + public String value; + } + + public boolean hasAttributesChange() { + return !operations.isEmpty(); + } +} diff --git a/src/main/java/jp/openstandia/connector/util/AbstractRESTClient.java b/src/main/java/jp/openstandia/connector/util/AbstractRESTClient.java new file mode 100644 index 0000000..0b92266 --- /dev/null +++ b/src/main/java/jp/openstandia/connector/util/AbstractRESTClient.java @@ -0,0 +1,429 @@ +/* + * Copyright Nomura Research Institute, Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package jp.openstandia.connector.util; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import okhttp3.*; +import okio.BufferedSource; +import org.identityconnectors.common.logging.Log; +import org.identityconnectors.framework.common.exceptions.*; +import org.identityconnectors.framework.common.objects.ObjectClass; +import org.identityconnectors.framework.common.objects.Uid; +import org.identityconnectors.framework.spi.Configuration; + +import java.io.IOException; +import java.util.List; +import java.util.Map; +import java.util.function.BiFunction; +import java.util.function.Function; + +public abstract class AbstractRESTClient { + + private static final Log LOG = Log.getLog(AbstractRESTClient.class); + + protected static final ObjectMapper MAPPER = new ObjectMapper(); + + protected String instanceName; + protected C configuration; + protected OkHttpClient httpClient; + protected ErrorHandler errorHandler; + protected boolean isStartOffsetFromZero; + protected String offsetKey; + protected String countKey; + protected int retryCount = 2; + + + public interface ErrorHandler { + boolean inNotAuthenticated(Response response); + + boolean isInvalidRequest(Response response); + + boolean isAlreadyExists(Response response); + + boolean isNotFound(Response response); + + boolean isOk(Response response); + + boolean isServerError(Response response); + } + + public void init(String instanceName, C configuration, OkHttpClient httpClient, ErrorHandler errorHandler, + boolean isStartOffsetFromZero, String offsetKey, String countKey) { + this.instanceName = instanceName; + this.configuration = configuration; + this.httpClient = httpClient; + this.errorHandler = errorHandler; + this.isStartOffsetFromZero = isStartOffsetFromZero; + this.offsetKey = offsetKey; + this.countKey = countKey; + } + + public abstract void test(); + + public void close() { + LOG.info("Close {0} connection, current: {1}, idle: {2}", + instanceName, httpClient.connectionPool().connectionCount(), httpClient.connectionPool().idleConnectionCount()); + httpClient.connectionPool().evictAll(); + } + + // Utilities + + /** + * Generic create method. + * + * @param objectClass + * @param url + * @param target + * @param name + * @return + */ + protected T callCreate(ObjectClass objectClass, String url, Object target, String name, Function callback) { + try (Response response = post(url, target)) { + if (errorHandler.isAlreadyExists(response)) { + throw new AlreadyExistsException(String.format("%s %s '%s' already exists.", instanceName, objectClass.getObjectClassValue(), name)); + } + if (errorHandler.isInvalidRequest(response)) { + throw new InvalidAttributeValueException(String.format("Bad request in create operation %s %s '%s': %s", instanceName, objectClass.getObjectClassValue(), name, toBody(response))); + } + + if (!this.errorHandler.isOk(response)) { + throw new ConnectorIOException(String.format("Failed to create %s %s '%s', statusCode: %d, response: %s", + instanceName, objectClass.getObjectClassValue(), name, response.code(), toBody(response))); + } + + // Success + return callback.apply(response); + + } catch (IOException e) { + throw new ConnectorIOException(String.format("Failed to create %s %s '%s'", + instanceName, objectClass.getObjectClassValue(), name), e); + } + } + + protected void callPatch(ObjectClass objectClass, String url, Uid uid, Object target) { + try (Response response = patch(url, target)) { + if (this.errorHandler.isNotFound(response)) { + throw new UnknownUidException(uid, objectClass); + } + + if (this.errorHandler.isInvalidRequest(response)) { + throw new InvalidAttributeValueException(String.format("Bad request in update operation %s %s: %s, response: %s", + this.instanceName, objectClass.getObjectClassValue(), uid.getUidValue(), toBody(response))); + } + + if (!this.errorHandler.isOk(response)) { + throw new ConnectorIOException(String.format("Failed to patch %s %s: %s, statusCode: %d, response: %s", + this.instanceName, objectClass.getObjectClassValue(), uid.getUidValue(), response.code(), toBody(response))); + } + + // Success + + } catch (IOException e) { + throw new ConnectorIOException(String.format("Failed to patch %s %s: %s", + this.instanceName, objectClass.getObjectClassValue(), uid.getUidValue()), e); + } + } + + protected void callUpdate(ObjectClass objectClass, String url, Uid uid, Object target) { + try (Response response = put(url, target)) { + if (this.errorHandler.isNotFound(response)) { + throw new UnknownUidException(uid, objectClass); + } + + if (this.errorHandler.isInvalidRequest(response)) { + throw new InvalidAttributeValueException(String.format("Bad request in replace operation %s %s: %s, response: %s", + this.instanceName, objectClass.getObjectClassValue(), uid.getUidValue(), toBody(response))); + } + + if (!this.errorHandler.isOk(response)) { + throw new ConnectorIOException(String.format("Failed to update %s %s: %s, statusCode: %d, response: %s", + this.instanceName, objectClass.getObjectClassValue(), uid.getUidValue(), response.code(), toBody(response))); + } + + // Success + + } catch (IOException e) { + throw new ConnectorIOException(String.format("Failed to update %s %s: %s", + this.instanceName, objectClass.getObjectClassValue(), uid.getUidValue()), e); + } + } + + private String toBody(Response response) { + ResponseBody resBody = response.body(); + if (resBody == null) { + return null; + } + try { + return resBody.string(); + } catch (IOException | IllegalStateException e) { + LOG.error(e, "Unexpected {0} API response", this.instanceName); + return ""; + } finally { + resBody.close(); + } + } + + /** + * Generic delete method. + * + * @param objectClass + * @param url + * @param uid + * @param body + */ + protected void callDelete(ObjectClass objectClass, String url, Uid uid, Object body) { + try (Response response = delete(url, body)) { + if (this.errorHandler.isNotFound(response)) { + throw new UnknownUidException(uid, objectClass); + } + + if (this.errorHandler.isInvalidRequest(response)) { + throw new InvalidAttributeValueException(String.format("Bad request in delete operation %s %s: %s, response: %s", + this.instanceName, objectClass.getObjectClassValue(), uid.getUidValue(), toBody(response))); + } + + if (!this.errorHandler.isOk(response)) { + throw new ConnectorIOException(String.format("Failed to delete %s %s: %s, statusCode: %d, response: %s", + this.instanceName, objectClass.getObjectClassValue(), uid.getUidValue(), response.code(), toBody(response))); + } + + // Success + + } catch (IOException e) { + throw new ConnectorIOException(String.format("Failed to delete %s %s: %s", + this.instanceName, objectClass.getObjectClassValue(), uid.getUidValue()), e); + } + } + + protected Response callRead(ObjectClass objectClass, String url, Uid uid) { + try { + Response response = get(url + "/" + uid.getUidValue()); + if (this.errorHandler.isNotFound(response)) { + // Don't return UnknownUidException in the Search (executeQuery) operations + return null; + } + + if (this.errorHandler.isInvalidRequest(response)) { + throw new InvalidAttributeValueException(String.format("Bad request in read operation for %s %s: %s, response: %s", + this.instanceName, objectClass.getObjectClassValue(), uid.getUidValue(), toBody(response))); + } + + if (!this.errorHandler.isOk(response)) { + throw new ConnectorIOException(String.format("Failed to read %s %s: %s, statusCode: %d, response: %s", + this.instanceName, objectClass.getObjectClassValue(), uid.getUidValue(), response.code(), toBody(response))); + } + + // Success + return response; + + } catch (IOException e) { + throw new ConnectorIOException(String.format("Failed to read %s %s: %s", + this.instanceName, objectClass.getObjectClassValue(), uid.getUidValue()), e); + } + } + + protected Response callSearch(ObjectClass objectClass, String url, Map params) { + try { + Response response = get(url, params); + if (this.errorHandler.isInvalidRequest(response)) { + throw new InvalidAttributeValueException(String.format("Bad request in search operation for %s %s: %s, response: %s", + this.instanceName, objectClass.getObjectClassValue(), params, toBody(response))); + } + + if (!this.errorHandler.isOk(response)) { + throw new ConnectorIOException(String.format("Failed to search %s %s: %s, statusCode: %d, response: %s", + this.instanceName, objectClass.getObjectClassValue(), params, response.code(), toBody(response))); + } + + // Success + return response; + + } catch (IOException e) { + throw new ConnectorIOException(String.format("Failed to search %s %s: %s", + this.instanceName, objectClass.getObjectClassValue(), params), e); + } + } + + private RequestBody createJsonRequestBody(Object body) { + String bodyString; + try { + bodyString = MAPPER.writeValueAsString(body); + } catch (JsonProcessingException e) { + throw new ConnectorIOException("Failed to write request json body", e); + } + + return RequestBody.create(bodyString, MediaType.parse("application/json; charset=UTF-8")); + } + + private void throwExceptionIfUnauthorized(Response response) throws ConnectorIOException { + if (this.errorHandler.inNotAuthenticated(response)) { + throw new ConnectionFailedException(String.format("Cannot authenticate to the %s REST API: %s", + this.instanceName, response.message())); + } + } + + private void throwExceptionIfServerError(Response response) throws ConnectorIOException { + if (this.errorHandler.isServerError(response)) { + try { + String body = response.body().string(); + throw new ConnectorIOException(this.instanceName + " server error: " + body); + } catch (IOException e) { + throw new ConnectorIOException(this.instanceName + " server error", e); + } + } + } + + protected Response get(String url) throws IOException { + return get(url, null); + } + + protected Response get(String url, Map params) throws IOException { + HttpUrl.Builder httpBuilder = HttpUrl.parse(url).newBuilder(); + + if (params != null) { + params.entrySet().stream().forEach(entry -> httpBuilder.addQueryParameter(entry.getKey(), entry.getValue())); + } + + final Request request = new Request.Builder() + .url(httpBuilder.build()) + .get() + .build(); + + final Response response; + response = httpClient.newCall(request).execute(); + + throwExceptionIfUnauthorized(response); + throwExceptionIfServerError(response); + + return response; + } + + protected int getAll(QueryHandler handler, int pageSize, BiFunction> apiCall) { + // Start offset (0 or 1) depends on the resource + int start = isStartOffsetFromZero ? 0 : 1; + int count = 0; + try { + while (true) { + List results = apiCall.apply(start, pageSize); + + if (results.size() == 0) { + // End of the page + return count; + } + + for (T result : results) { + count++; + if (!handler.handle(result)) { + return count; + } + } + + // search next page + start += pageSize; + } + } catch (RuntimeException e) { + if (!(e instanceof ConnectorException)) { + throw new ConnectorException(e); + } + throw e; + } + } + + protected int resolveOffset(int pageOffset) { + // The page offset depends on the resource + return isStartOffsetFromZero ? pageOffset - 1 : pageOffset; + } + + private Response post(String url, Object body) throws IOException { + RequestBody requestBody = createJsonRequestBody(body); + + final Request request = new Request.Builder() + .url(url) + .post(requestBody) + .build(); + + final Response response = httpClient.newCall(request).execute(); + + throwExceptionIfUnauthorized(response); + throwExceptionIfServerError(response); + + return response; + } + + private Response put(String url, Object body) throws IOException { + RequestBody requestBody = createJsonRequestBody(body); + + final Request request = new Request.Builder() + .url(url) + .put(requestBody) + .build(); + + final Response response = httpClient.newCall(request).execute(); + + throwExceptionIfUnauthorized(response); + throwExceptionIfServerError(response); + + return response; + } + + private Response patch(String url, Object body) throws IOException { + RequestBody requestBody = createJsonRequestBody(body); + + final Request request = new Request.Builder() + .url(url) + .patch(requestBody) + .build(); + + final Response response = httpClient.newCall(request).execute(); + + throwExceptionIfUnauthorized(response); + throwExceptionIfServerError(response); + + return response; + } + + private Response delete(String url, Object body) throws IOException { + final Request.Builder builder = new Request.Builder() + .url(url); + + if (body != null) { + RequestBody requestBody = createJsonRequestBody(body); + builder.delete(requestBody); + } else { + builder.delete(); + } + + final Request request = builder.build(); + + final Response response = httpClient.newCall(request).execute(); + + throwExceptionIfUnauthorized(response); + throwExceptionIfServerError(response); + + return response; + } + + protected String snapshotResponse(Response response) { + final BufferedSource source = response.body().source(); + try { + source.request(Integer.MAX_VALUE); + } catch (IOException e) { + return null; + } + return source.getBuffer().snapshot().utf8(); + } +} \ No newline at end of file diff --git a/src/main/java/jp/openstandia/connector/util/ObjectHandler.java b/src/main/java/jp/openstandia/connector/util/ObjectHandler.java new file mode 100644 index 0000000..d5c870e --- /dev/null +++ b/src/main/java/jp/openstandia/connector/util/ObjectHandler.java @@ -0,0 +1,60 @@ +/* + * Copyright Nomura Research Institute, Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package jp.openstandia.connector.util; + +import org.identityconnectors.framework.common.objects.*; + +import java.util.Set; + +/** + * Define handler methods for connector operations. + * + * @author Hiroyuki Wada + */ +public interface ObjectHandler { + + Uid create(Set attributes); + + Set updateDelta(Uid uid, Set modifications, OperationOptions options); + + void delete(Uid uid, OperationOptions options); + + int getByUid(Uid uid, ResultsHandler resultsHandler, OperationOptions options, + Set returnAttributesSet, Set fetchFieldsSet, + boolean allowPartialAttributeValues, int pageSize, int pageOffset); + + int getByName(Name name, ResultsHandler resultsHandler, OperationOptions options, + Set returnAttributesSet, Set fetchFieldsSet, + boolean allowPartialAttributeValues, int pageSize, int pageOffset); + + default int getByMembers(Attribute attribute, ResultsHandler resultsHandler, OperationOptions options, + Set returnAttributesSet, Set fetchFieldSet, boolean allowPartialAttributeValues, int pageSize, int pageOffset) { + return 0; + } + + int getAll(ResultsHandler resultsHandler, OperationOptions options, + Set returnAttributesSet, Set fetchFieldsSet, + boolean allowPartialAttributeValues, int pageSize, int pageOffset); + + default ConnectorObject toConnectorObject(SchemaDefinition schema, T user, + Set returnAttributesSet, boolean allowPartialAttributeValues) { + ConnectorObjectBuilder builder = schema.toConnectorObjectBuilder(user, returnAttributesSet, allowPartialAttributeValues); + return builder.build(); + } + + SchemaDefinition getSchema(); + +} diff --git a/src/main/java/jp/openstandia/connector/util/QueryHandler.java b/src/main/java/jp/openstandia/connector/util/QueryHandler.java new file mode 100644 index 0000000..54ba65b --- /dev/null +++ b/src/main/java/jp/openstandia/connector/util/QueryHandler.java @@ -0,0 +1,6 @@ +package jp.openstandia.connector.util; + +@FunctionalInterface +public interface QueryHandler { + boolean handle(T arg); +} \ No newline at end of file diff --git a/src/main/java/jp/openstandia/connector/util/SchemaDefinition.java b/src/main/java/jp/openstandia/connector/util/SchemaDefinition.java new file mode 100644 index 0000000..4efc732 --- /dev/null +++ b/src/main/java/jp/openstandia/connector/util/SchemaDefinition.java @@ -0,0 +1,763 @@ +/* + * Copyright Nomura Research Institute, Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package jp.openstandia.connector.util; + +import org.identityconnectors.common.security.GuardedString; +import org.identityconnectors.framework.common.exceptions.InvalidAttributeValueException; +import org.identityconnectors.framework.common.objects.*; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.time.format.DateTimeFormatter; +import java.util.*; +import java.util.function.BiConsumer; +import java.util.function.Function; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import static jp.openstandia.connector.util.Utils.createIncompleteAttribute; +import static jp.openstandia.connector.util.Utils.shouldReturn; + +/** + * Provides generic schema builder. + * + * @author Hiroyuki Wada + */ +public class SchemaDefinition { + + public static Builder newBuilder(ObjectClass objectClass, Class createOrUpdateClass, Class readClass) { + return newBuilder(objectClass, createOrUpdateClass, createOrUpdateClass, readClass); + } + + public static Builder newBuilder(ObjectClass objectClass, Class createClass, Class updateClass, Class readClass) { + Builder schemaBuilder = new Builder(objectClass, createClass, updateClass, readClass); + return schemaBuilder; + } + + public static class Builder { + private final ObjectClass objectClass; + private final List attributes = new ArrayList<>(); + + public Builder(ObjectClass objectClass, Class createClass, Class updateClass, Class readClass) { + this.objectClass = objectClass; + } + + public void addUid(String name, + Types typeClass, + + BiConsumer create, + BiConsumer update, + Function read, + + String fetchField, + + AttributeInfo.Flags... options + ) { + AttributeMapper attr = new AttributeMapper(Uid.NAME, name, typeClass, create, update, read, fetchField, options); + this.attributes.add(attr); + } + + public void addUid(String name, + Types typeClass, + + BiConsumer createOrUpdate, + Function read, + + String fetchField, + + AttributeInfo.Flags... options + ) { + AttributeMapper attr = new AttributeMapper(Uid.NAME, name, typeClass, createOrUpdate, createOrUpdate, read, fetchField, options); + this.attributes.add(attr); + } + + public void addName(String name, + Types typeClass, + + BiConsumer create, + BiConsumer update, + Function read, + + String fetchField, + + AttributeInfo.Flags... options + ) { + AttributeMapper attr = new AttributeMapper(Name.NAME, name, typeClass, create, update, read, fetchField, options); + this.attributes.add(attr); + } + + public void addName(String name, + Types typeClass, + + BiConsumer createOrUpdate, + Function read, + + String fetchField, + + AttributeInfo.Flags... options + ) { + AttributeMapper attr = new AttributeMapper(Name.NAME, name, typeClass, createOrUpdate, createOrUpdate, read, fetchField, options); + this.attributes.add(attr); + } + + public void add(String name, + Types typeClass, + + BiConsumer create, + BiConsumer update, + Function read, + + String fetchField, + + AttributeInfo.Flags... options + ) { + AttributeMapper attr = new AttributeMapper(name, typeClass, create, update, read, fetchField, options); + this.attributes.add(attr); + } + + public void add(String name, + Types typeClass, + + BiConsumer createOrUpdate, + Function read, + + String fetchField, + + AttributeInfo.Flags... options + ) { + AttributeMapper attr = new AttributeMapper(name, typeClass, createOrUpdate, createOrUpdate, read, fetchField, options); + this.attributes.add(attr); + } + + public void addAsMultiple(String name, + Types typeClass, + + BiConsumer, C> create, + BiConsumer, U> updateAdd, + BiConsumer, U> updateRemove, + Function> read, + + String fetchField, + + AttributeInfo.Flags... options + ) { + AttributeMapper attr = new AttributeMapper(name, typeClass, create, updateAdd, updateRemove, read, fetchField, options); + this.attributes.add(attr); + } + + public SchemaDefinition build() { + SchemaDefinition schemaDefinition = new SchemaDefinition(objectClass, buildSchemaInfo(), buildAttributeMap()); + return schemaDefinition; + } + + private ObjectClassInfo buildSchemaInfo() { + List list = attributes.stream() + .map(attr -> { + AttributeInfoBuilder define = AttributeInfoBuilder.define(attr.connectorName); + + define.setType(attr.type.typeClass); + define.setMultiValued(attr.isMultiple); + define.setNativeName(attr.name); + + if (attr.type == Types.UUID) { + define.setSubtype(AttributeInfo.Subtypes.STRING_UUID); + + } else if (attr.type == Types.STRING_CASE_IGNORE) { + define.setSubtype(AttributeInfo.Subtypes.STRING_CASE_IGNORE); + + } else if (attr.type == Types.STRING_URI) { + define.setSubtype(AttributeInfo.Subtypes.STRING_URI); + + } else if (attr.type == Types.STRING_LDAP_DN) { + define.setSubtype(AttributeInfo.Subtypes.STRING_LDAP_DN); + + } else if (attr.type == Types.XML) { + define.setSubtype(AttributeInfo.Subtypes.STRING_XML); + + } else if (attr.type == Types.JSON) { + define.setSubtype(AttributeInfo.Subtypes.STRING_JSON); + } + + for (AttributeInfo.Flags option : attr.options) { + switch (option) { + case REQUIRED: { + define.setRequired(true); + break; + } + case NOT_CREATABLE: { + define.setCreateable(false); + break; + } + case NOT_UPDATEABLE: { + define.setUpdateable(false); + break; + } + case NOT_READABLE: { + define.setReadable(false); + break; + } + case NOT_RETURNED_BY_DEFAULT: { + define.setReturnedByDefault(false); + break; + } + } + } + + return define.build(); + }) + .collect(Collectors.toList()); + + ObjectClassInfoBuilder builder = new ObjectClassInfoBuilder(); + builder.setType(objectClass.getObjectClassValue()); + builder.addAllAttributeInfo(list); + + return builder.build(); + } + + private Map buildAttributeMap() { + Map map = attributes.stream() + // Use connectorName for the key (to lookup by special name like __UID__ + .collect(Collectors.toMap(a -> a.connectorName, a -> a)); + return map; + } + } + + private final ObjectClass objectClass; + private final ObjectClassInfo objectClassInfo; + private final Map attributeMap; + // Key: attribute name (for connector. e.g. __NAME__) + // Value: field name for resource fetching + private final Map returnedByDefaultAttributesSet; + private final Map notReadableAttributesSet; + + public SchemaDefinition(ObjectClass objectClass, ObjectClassInfo objectClassInfo, Map attributeMap) { + this.objectClass = objectClass; + this.objectClassInfo = objectClassInfo; + this.attributeMap = attributeMap; + this.returnedByDefaultAttributesSet = getObjectClassInfo().getAttributeInfo().stream() + .filter(i -> i.isReturnedByDefault()) + .map(i -> i.getName()) + .collect(Collectors.toMap(n -> n, n -> attributeMap.get(n).fetchField)); + this.notReadableAttributesSet = getObjectClassInfo().getAttributeInfo().stream() + .filter(i -> !i.isReadable()) + .map(i -> i.getName()) + .collect(Collectors.toMap(n -> n, n -> attributeMap.get(n).fetchField)); + } + + public ObjectClassInfo getObjectClassInfo() { + return objectClassInfo; + } + + public Map getReturnedByDefaultAttributesSet() { + return returnedByDefaultAttributesSet; + } + + public boolean isReturnedByDefaultAttribute(String attrName) { + return returnedByDefaultAttributesSet.containsKey(attrName); + } + + public boolean isReadableAttributes(String attrName) { + return !notReadableAttributesSet.containsKey(attrName); + } + + public String getFetchField(String name) { + AttributeMapper attributeMapper = attributeMap.get(name); + if (attributeMapper != null) { + return attributeMapper.fetchField; + } + return null; + } + + public T apply(Set attrs, T dest) { + for (Attribute attr : attrs) { + AttributeMapper attributeMapper = attributeMap.get(attr.getName()); + if (attributeMapper == null) { + throw new InvalidAttributeValueException("Invalid attribute: " + attr.getName()); + } + + attributeMapper.apply(attr, dest); + } + return dest; + } + + public boolean applyDelta(Set deltas, U dest) { + boolean changed = false; + for (AttributeDelta delta : deltas) { + AttributeMapper attributeMapper = attributeMap.get(delta.getName()); + if (attributeMapper == null) { + throw new InvalidAttributeValueException("Invalid attribute: " + delta.getName()); + } + + attributeMapper.apply(delta, dest); + changed = true; + } + return changed; + } + + public ConnectorObjectBuilder toConnectorObjectBuilder(R source, Set attributesToGet, boolean allowPartialAttributeValues) { + final ConnectorObjectBuilder builder = new ConnectorObjectBuilder() + .setObjectClass(objectClass); + + AttributeMapper uid = attributeMap.get(Uid.NAME); + addAttribute(builder, uid.apply(source)); + + // Need to set __NAME__ because it throws IllegalArgumentException + AttributeMapper name = attributeMap.get(Name.NAME); + addAttribute(builder, name.apply(source)); + + for (Map.Entry entry : attributeMap.entrySet()) { + // When requested partial attribute values, return incomplete attribute if the attribute is not returned by default and readable + if (allowPartialAttributeValues) { + if (!isReturnedByDefaultAttribute(entry.getKey()) && isReadableAttributes(entry.getKey()) + && attributesToGet.contains(entry.getKey())) { + addAttribute(builder, createIncompleteAttribute(entry.getKey())); + continue; + } + } + if (shouldReturn(attributesToGet, entry.getKey())) { + Attribute value = entry.getValue().apply(source); + addAttribute(builder, value); + } + } + + return builder; + } + + protected void addAttribute(ConnectorObjectBuilder builder, Attribute attribute) { + if (attribute == null) { + return; + } + // Don't set null because it causes NPE + builder.addAttribute(attribute); + } + + public String getType() { + return objectClassInfo.getType(); + } + + public static class Types { + public static final Types STRING = new Types(String.class); + public static final Types STRING_CASE_IGNORE = new Types(String.class); + public static final Types STRING_URI = new Types(String.class); + public static final Types STRING_LDAP_DN = new Types(String.class); + public static final Types XML = new Types(String.class); + public static final Types JSON = new Types(String.class); + public static final Types UUID = new Types(String.class); + public static final Types INTEGER = new Types(Integer.class); + public static final Types LONG = new Types(Long.class); + public static final Types FLOAT = new Types(Float.class); + public static final Types DOUBLE = new Types(Double.class); + public static final Types BOOLEAN = new Types(Boolean.class); + public static final Types BIG_DECIMAL = new Types(BigDecimal.class); + public static final Types DATE_STRING = new Types(ZonedDateTime.class); + public static final Types DATETIME_STRING = new Types(ZonedDateTime.class); + public static final Types DATE = new Types(ZonedDateTime.class); + public static final Types DATETIME = new Types(ZonedDateTime.class); + public static final Types GUARDED_STRING = new Types(GuardedString.class); + + private final Class typeClass; + + private Types(Class typeClass) { + this.typeClass = typeClass; + } + } + + static class AttributeMapper { + private final String connectorName; + private final String name; + private final Types type; + boolean isMultiple; + + private final BiConsumer create; + private final BiConsumer replace; + private final BiConsumer, U> add; + private final BiConsumer, U> remove; + private final Function read; + + private final String fetchField; + + private final AttributeInfo.Flags[] options; + + private DateTimeFormatter dateFormat; + private DateTimeFormatter dateTimeFormat; + + private static final DateTimeFormatter DEFAULT_DATE_FORMAT = DateTimeFormatter.ISO_LOCAL_DATE; + private static final DateTimeFormatter DEFAULT_DATE_TIME_FORMAT = DateTimeFormatter.ISO_OFFSET_DATE_TIME; + + public AttributeMapper(String connectorName, String name, Types typeClass, + BiConsumer create, + BiConsumer replace, + Function read, + String fetchField, + AttributeInfo.Flags... options + ) { + this(connectorName, name, typeClass, create, replace, null, null, read, fetchField, false, options); + } + + public AttributeMapper(String name, Types typeClass, + BiConsumer create, + BiConsumer replace, + Function read, + String fetchField, + AttributeInfo.Flags... options + ) { + this(name, name, typeClass, create, replace, null, null, read, fetchField, false, options); + } + + public AttributeMapper(String name, Types typeClass, + BiConsumer create, + BiConsumer, U> add, + BiConsumer, U> remove, + Function read, + String fetchField, + AttributeInfo.Flags... options + ) { + this(name, name, typeClass, create, null, add, remove, read, fetchField, true, options); + } + + public AttributeMapper(String connectorName, String name, Types typeClass, + BiConsumer create, + BiConsumer replace, + BiConsumer, U> add, + BiConsumer, U> remove, + Function read, + String fetchField, + boolean isMultiple, + AttributeInfo.Flags... options + ) { + this.connectorName = connectorName; + this.name = name; + this.type = typeClass; + this.create = create; + this.replace = replace; + this.add = add; + this.remove = remove; + this.read = read; + this.fetchField = fetchField != null ? fetchField : name; + this.options = options; + this.isMultiple = isMultiple; + } + + public boolean isStringType() { + return type == Types.STRING || type == Types.STRING_URI || type == Types.STRING_LDAP_DN || + type == Types.STRING_LDAP_DN || type == Types.STRING_CASE_IGNORE || type == Types.XML || + type == Types.JSON || type == Types.UUID; + } + + public AttributeMapper dateFormat(DateTimeFormatter dateFormat) { + this.dateFormat = dateFormat; + return this; + } + + public AttributeMapper datetimeFormat(DateTimeFormatter datetimeFormat) { + this.dateTimeFormat = datetimeFormat; + return this; + } + + private String formatDate(ZonedDateTime zonedDateTime) { + if (zonedDateTime == null) { + return null; + } + if (this.dateFormat == null) { + return zonedDateTime.format(DEFAULT_DATE_FORMAT); + } + return zonedDateTime.format(this.dateFormat); + } + + private String formatDateTime(ZonedDateTime zonedDateTime) { + if (zonedDateTime == null) { + return null; + } + if (this.dateTimeFormat == null) { + return zonedDateTime.format(DEFAULT_DATE_TIME_FORMAT); + } + return zonedDateTime.format(this.dateFormat); + } + + private ZonedDateTime toDate(String dateString) { + LocalDate date; + if (this.dateFormat == null) { + date = LocalDate.parse(dateString, DEFAULT_DATE_FORMAT); + } else { + date = LocalDate.parse(dateString, this.dateFormat); + } + return date.atStartOfDay(ZoneId.systemDefault()); + } + + private ZonedDateTime toDateTime(String dateTimeString) { + ZonedDateTime dateTime; + if (this.dateTimeFormat == null) { + dateTime = ZonedDateTime.parse(dateTimeString, DEFAULT_DATE_TIME_FORMAT); + } else { + dateTime = ZonedDateTime.parse(dateTimeString, this.dateTimeFormat); + } + return dateTime; + } + + public void apply(Attribute source, C dest) { + if (create == null) { + return; + } + + if (isMultiple) { + if (type == Types.DATE_STRING) { + List values = source.getValue().stream() + .map(v -> (ZonedDateTime) v) + .map(v -> (T) formatDate(v)) + .collect(Collectors.toList()); + create.accept((T) values, dest); + + } else if (type == Types.DATETIME_STRING) { + List values = source.getValue().stream() + .map(v -> (ZonedDateTime) v) + .map(v -> (T) formatDateTime(v)) + .collect(Collectors.toList()); + create.accept((T) values, dest); + + } else { + List values = source.getValue().stream().map(v -> (T) v).collect(Collectors.toList()); + create.accept((T) values, dest); + } + + } else { + if (isStringType()) { + String value = AttributeUtil.getAsStringValue(source); + create.accept((T) value, dest); + + } else if (type == Types.INTEGER) { + Integer value = AttributeUtil.getIntegerValue(source); + create.accept((T) value, dest); + + } else if (type == Types.LONG) { + Long value = AttributeUtil.getLongValue(source); + create.accept((T) value, dest); + + } else if (type == Types.FLOAT) { + Float value = AttributeUtil.getFloatValue(source); + create.accept((T) value, dest); + + } else if (type == Types.DOUBLE) { + Double value = AttributeUtil.getDoubleValue(source); + create.accept((T) value, dest); + + } else if (type == Types.BOOLEAN) { + Boolean value = AttributeUtil.getBooleanValue(source); + create.accept((T) value, dest); + + } else if (type == Types.BIG_DECIMAL) { + BigDecimal value = AttributeUtil.getBigDecimalValue(source); + create.accept((T) value, dest); + + } else if (type == Types.DATE || type == Types.DATETIME) { + ZonedDateTime date = (ZonedDateTime) AttributeUtil.getSingleValue(source); + String formatted = formatDate(date); + create.accept((T) formatted, dest); + + } else if (type == Types.DATE_STRING) { + ZonedDateTime date = (ZonedDateTime) AttributeUtil.getSingleValue(source); + String formatted = formatDate(date); + create.accept((T) formatted, dest); + + } else if (type == Types.DATETIME_STRING) { + ZonedDateTime date = (ZonedDateTime) AttributeUtil.getSingleValue(source); + String formatted = formatDateTime(date); + create.accept((T) formatted, dest); + + } else if (type == Types.GUARDED_STRING) { + GuardedString guardedString = AttributeUtil.getGuardedStringValue(source); + create.accept((T) guardedString, dest); + + } else { + T value = (T) AttributeUtil.getSingleValue(source); + create.accept(value, dest); + } + } + } + + public void apply(AttributeDelta source, U dest) { + if (isMultiple) { + if (add == null || remove == null) { + return; + } + + if (type == Types.DATE_STRING) { + List valuesToAdd = safeStream(source.getValuesToAdd()) + .map(v -> (ZonedDateTime) v) + .map(v -> (T) formatDate(v)) + .collect(Collectors.toList()); + List valuesToRemove = safeStream(source.getValuesToRemove()) + .map(v -> (ZonedDateTime) v) + .map(v -> (T) formatDate(v)) + .collect(Collectors.toList()); + + if (!valuesToAdd.isEmpty()) { + add.accept(valuesToAdd, dest); + } + if (!valuesToRemove.isEmpty()) { + remove.accept(valuesToRemove, dest); + } + + } else if (type == Types.DATETIME_STRING) { + List valuesToAdd = safeStream(source.getValuesToAdd()) + .map(v -> (ZonedDateTime) v) + .map(v -> (T) formatDateTime(v)) + .collect(Collectors.toList()); + List valuesToRemove = safeStream(source.getValuesToRemove()) + .map(v -> (ZonedDateTime) v) + .map(v -> (T) formatDateTime(v)) + .collect(Collectors.toList()); + + if (!valuesToAdd.isEmpty()) { + add.accept(valuesToAdd, dest); + } + if (!valuesToRemove.isEmpty()) { + remove.accept(valuesToRemove, dest); + } + + } else { + List valuesToAdd = safeStream(source.getValuesToAdd()).map(v -> (T) v).collect(Collectors.toList()); + List valuesToRemove = safeStream(source.getValuesToRemove()).map(v -> (T) v).collect(Collectors.toList()); + + if (!valuesToAdd.isEmpty()) { + add.accept(valuesToAdd, dest); + } + if (!valuesToRemove.isEmpty()) { + remove.accept(valuesToRemove, dest); + } + } + + } else { + if (replace == null) { + return; + } + + if (isStringType()) { + String value = AttributeDeltaUtil.getAsStringValue(source); + replace.accept((T) value, dest); + + } else if (type == Types.INTEGER) { + Integer value = AttributeDeltaUtil.getIntegerValue(source); + replace.accept((T) value, dest); + + } else if (type == Types.LONG) { + Long value = AttributeDeltaUtil.getLongValue(source); + replace.accept((T) value, dest); + + } else if (type == Types.FLOAT) { + Float value = AttributeDeltaUtil.getFloatValue(source); + replace.accept((T) value, dest); + + } else if (type == Types.DOUBLE) { + Double value = AttributeDeltaUtil.getDoubleValue(source); + replace.accept((T) value, dest); + + } else if (type == Types.BOOLEAN) { + Boolean value = AttributeDeltaUtil.getBooleanValue(source); + replace.accept((T) value, dest); + + } else if (type == Types.BIG_DECIMAL) { + BigDecimal value = AttributeDeltaUtil.getBigDecimalValue(source); + replace.accept((T) value, dest); + + } else if (type == Types.DATE || type == Types.DATETIME) { + ZonedDateTime date = (ZonedDateTime) AttributeDeltaUtil.getSingleValue(source); + replace.accept((T) date, dest); + + } else if (type == Types.DATE_STRING) { + ZonedDateTime date = (ZonedDateTime) AttributeDeltaUtil.getSingleValue(source); + String formatted = formatDate(date); + replace.accept((T) formatted, dest); + + } else if (type == Types.DATETIME_STRING) { + ZonedDateTime date = (ZonedDateTime) AttributeDeltaUtil.getSingleValue(source); + String formatted = formatDateTime(date); + replace.accept((T) formatted, dest); + + } else if (type == Types.GUARDED_STRING) { + GuardedString guardedString = AttributeDeltaUtil.getGuardedStringValue(source); + replace.accept((T) guardedString, dest); + + } else { + T value = (T) AttributeDeltaUtil.getSingleValue(source); + replace.accept(value, dest); + } + } + } + + public Attribute apply(R source) { + if (read == null) { + return null; + } + + Object value = read.apply(source); + if (value == null) { + // Don't make attribute if no value + return null; + } + + if (isMultiple) { + Stream multipleValues = (Stream) value; + + if (type == Types.DATE_STRING) { + List values = multipleValues + .map(v -> (String) v) + .map(v -> toDate(v)) + .collect(Collectors.toList()); + return safeBuildAttribute(values); + + } else if (type == Types.DATETIME_STRING) { + List values = multipleValues + .map(v -> (String) v) + .map(v -> toDateTime(v)) + .collect(Collectors.toList()); + return safeBuildAttribute(values); + + } else { + List values = multipleValues.collect(Collectors.toList()); + return safeBuildAttribute(values); + } + + } else { + if (type == Types.DATE_STRING) { + ZonedDateTime date = toDate(value.toString()); + return AttributeBuilder.build(connectorName, date); + + } else if (type == Types.DATETIME_STRING) { + ZonedDateTime dateTime = toDateTime(value.toString()); + return AttributeBuilder.build(connectorName, dateTime); + } + return AttributeBuilder.build(connectorName, value); + } + } + + private Stream safeStream(List list) { + if (list == null) { + return Collections.emptyList().stream(); + } + return list.stream(); + } + + private Attribute safeBuildAttribute(List values) { + if (values.isEmpty()) { + // Don't make attribute if no values + return null; + } + return AttributeBuilder.build(connectorName, values); + } + } +} \ No newline at end of file diff --git a/src/main/java/jp/openstandia/connector/util/Utils.java b/src/main/java/jp/openstandia/connector/util/Utils.java new file mode 100644 index 0000000..bedff97 --- /dev/null +++ b/src/main/java/jp/openstandia/connector/util/Utils.java @@ -0,0 +1,199 @@ +/* + * Copyright Nomura Research Institute, Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package jp.openstandia.connector.util; + +import org.identityconnectors.common.logging.Log; +import org.identityconnectors.framework.common.objects.Attribute; +import org.identityconnectors.framework.common.objects.AttributeBuilder; +import org.identityconnectors.framework.common.objects.AttributeValueCompleteness; +import org.identityconnectors.framework.common.objects.OperationOptions; + +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.time.format.DateTimeFormatter; +import java.util.*; + +/** + * Provides utility methods + * + * @author Hiroyuki Wada + */ +public class Utils { + private static final Log LOG = Log.getLog(Utils.class); + + public static ZonedDateTime toZoneDateTime(String yyyymmdd) { + if (yyyymmdd == null) { + return null; + } + LocalDate date = LocalDate.parse(yyyymmdd); + return date.atStartOfDay(ZoneId.systemDefault()); + } + + public static ZonedDateTime toZoneDateTime(DateTimeFormatter formatter, String datetimeString) { + if (datetimeString == null) { + return null; + } + Instant instant = Instant.from(formatter.parse(datetimeString)); + ZoneId zone = ZoneId.systemDefault(); + return ZonedDateTime.ofInstant(instant, zone); + } + + public static ZonedDateTime toZoneDateTimeForEpochMilli(String epoch) { + if (epoch == null) { + return null; + } + Instant instant = Instant.ofEpochMilli(Long.parseLong(epoch)); + ZoneId zone = ZoneId.systemDefault(); + return ZonedDateTime.ofInstant(instant, zone); + } + + public static ZonedDateTime toZoneDateTimeForISO8601OffsetDateTime(String datetimeString) { + if (datetimeString == null) { + return null; + } + return ZonedDateTime.parse(datetimeString, DateTimeFormatter.ISO_OFFSET_DATE_TIME) + .withZoneSameInstant(ZoneId.systemDefault()); + } + + public static ZonedDateTime toZoneDateTime(Date date) { + if (date == null) { + return null; + } + ZoneId zone = ZoneId.systemDefault(); + return ZonedDateTime.ofInstant(date.toInstant(), zone); + } + + /** + * Check if attrsToGetSet contains the attribute. + * + * @param attrsToGetSet + * @param attr + * @param isReturnByDefault + * @return + */ + public static boolean shouldReturn(Set attrsToGetSet, String attr, boolean isReturnByDefault) { + if (attrsToGetSet == null) { + return isReturnByDefault; + } + return attrsToGetSet.contains(attr); + } + + public static boolean shouldReturn(Set attrsToGetSet, String attr) { + return attrsToGetSet.contains(attr); + } + + public static Attribute createIncompleteAttribute(String attr) { + AttributeBuilder builder = new AttributeBuilder(); + builder.setName(attr).setAttributeValueCompleteness(AttributeValueCompleteness.INCOMPLETE); + builder.addValue(Collections.EMPTY_LIST); + return builder.build(); + } + + /** + * Check if ALLOW_PARTIAL_ATTRIBUTE_VALUES == true. + * + * @param options + * @return + */ + public static boolean shouldAllowPartialAttributeValues(OperationOptions options) { + // If the option isn't set from IDM, it may be null. + return Boolean.TRUE.equals(options.getAllowPartialAttributeValues()); + } + + /** + * Check if RETURN_DEFAULT_ATTRIBUTES == true. + * + * @param options + * @return + */ + public static boolean shouldReturnDefaultAttributes(OperationOptions options) { + // If the option isn't set from IDM, it may be null. + return Boolean.TRUE.equals(options.getReturnDefaultAttributes()); + } + + /** + * Create full map of ATTRIBUTES_TO_GET which is composed by RETURN_DEFAULT_ATTRIBUTES + ATTRIBUTES_TO_GET. + * Key: attribute name of the connector (e.g. __UID__) + * Value: field name for resource fetching + * + * @param schema + * @param options + * @return + */ + public static Map createFullAttributesToGet(SchemaDefinition schema, OperationOptions options) { + Map attributesToGet = new HashMap<>(); + + if (shouldReturnDefaultAttributes(options)) { + attributesToGet.putAll(toReturnedByDefaultAttributesSet(schema)); + } + + if (options.getAttributesToGet() != null) { + for (String a : options.getAttributesToGet()) { + String fetchField = schema.getFetchField(a); + if (fetchField == null) { + LOG.warn("Requested unknown attribute to get. Ignored it: {0}", a); + continue; + } + attributesToGet.put(a, fetchField); + } + } + + // If ATTRS_TO_GET option is not present (also, RETURN_DEFAULT_ATTRIBUTES option is not present too), + // then the connector should return only those attributes that the resource returns by default. + if (options.getAttributesToGet() == null && options.getReturnDefaultAttributes() == null) { + attributesToGet.putAll(toReturnedByDefaultAttributesSet(schema)); + } + + return attributesToGet; + } + + private static Map toReturnedByDefaultAttributesSet(SchemaDefinition schema) { + return schema.getReturnedByDefaultAttributesSet(); + } + + public static int resolvePageSize(OperationOptions options, int defaultPageSize) { + if (options.getPageSize() != null) { + return options.getPageSize(); + } + return defaultPageSize; + } + + public static int resolvePageOffset(OperationOptions options) { + if (options.getPagedResultsOffset() != null) { + return options.getPagedResultsOffset(); + } + return 0; + } + + public static String handleEmptyAsNull(String s) { + if (s == null) { + return null; + } + if (s.isEmpty()) { + return null; + } + return s; + } + + public static String handleNullAsEmpty(String s) { + if (s == null) { + return ""; + } + return s; + } +} diff --git a/src/test/java/jp/openstandia/connector/atlassian/AtlassianGuardUtilsTest.java b/src/test/java/jp/openstandia/connector/atlassian/AtlassianGuardUtilsTest.java new file mode 100644 index 0000000..11de3b5 --- /dev/null +++ b/src/test/java/jp/openstandia/connector/atlassian/AtlassianGuardUtilsTest.java @@ -0,0 +1,80 @@ +/* + * Copyright Nomura Research Institute, Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package jp.openstandia.connector.atlassian; + +import jp.openstandia.connector.util.SchemaDefinition; +import jp.openstandia.connector.util.Utils; +import org.identityconnectors.framework.common.objects.Name; +import org.identityconnectors.framework.common.objects.OperationOptions; +import org.identityconnectors.framework.common.objects.OperationOptionsBuilder; +import org.identityconnectors.framework.common.objects.Uid; +import org.junit.jupiter.api.Test; + +import java.util.Map; + +import static org.identityconnectors.framework.common.objects.AttributeInfo.Flags.*; +import static org.junit.jupiter.api.Assertions.*; + +class AtlassianGuardUtilsTest { + + @Test + void shouldReturnPartialAttributeValues() { + OperationOptions noOptions = new OperationOptionsBuilder().build(); + assertFalse(Utils.shouldAllowPartialAttributeValues(noOptions)); + + OperationOptions falseOption = new OperationOptionsBuilder().setAllowPartialAttributeValues(false).build(); + assertFalse(Utils.shouldAllowPartialAttributeValues(falseOption)); + + OperationOptions trueOption = new OperationOptionsBuilder().setAllowPartialAttributeValues(true).build(); + assertTrue(Utils.shouldAllowPartialAttributeValues(trueOption)); + } + + @Test + void createFullAttributesToGet() { + SchemaDefinition.Builder builder = SchemaDefinition.newBuilder(AtlassianGuardUserHandler.USER_OBJECT_CLASS, AtlassianGuardUserModel.class, AtlassianGuardUserModel.class); + builder.addUid("userId", + SchemaDefinition.Types.STRING_CASE_IGNORE, + null, + (source) -> source.id, + null, + NOT_CREATABLE, NOT_UPDATEABLE + ); + builder.addName("userName", + SchemaDefinition.Types.STRING_CASE_IGNORE, + (source, dest) -> dest.userName = source, + (source) -> source.userName, + null, + REQUIRED + ); + SchemaDefinition schemaDefinition = builder.build(); + + OperationOptions noOptions = new OperationOptionsBuilder().build(); + Map fullAttributesToGet = Utils.createFullAttributesToGet(schemaDefinition, noOptions); + assertEquals(2, fullAttributesToGet.size()); + assertTrue(fullAttributesToGet.containsKey(Uid.NAME)); + assertTrue(fullAttributesToGet.containsKey(Name.NAME)); + assertEquals("userId", fullAttributesToGet.get(Uid.NAME)); + assertEquals("userName", fullAttributesToGet.get(Name.NAME)); + + OperationOptions returnDefaultAttributes = new OperationOptionsBuilder().setReturnDefaultAttributes(true).build(); + fullAttributesToGet = Utils.createFullAttributesToGet(schemaDefinition, returnDefaultAttributes); + assertEquals(2, fullAttributesToGet.size()); + assertTrue(fullAttributesToGet.containsKey(Uid.NAME)); + assertTrue(fullAttributesToGet.containsKey(Name.NAME)); + assertEquals("userId", fullAttributesToGet.get(Uid.NAME)); + assertEquals("userName", fullAttributesToGet.get(Name.NAME)); + } +} \ No newline at end of file diff --git a/src/test/java/jp/openstandia/connector/atlassian/GroupTest.java b/src/test/java/jp/openstandia/connector/atlassian/GroupTest.java new file mode 100644 index 0000000..2ab313a --- /dev/null +++ b/src/test/java/jp/openstandia/connector/atlassian/GroupTest.java @@ -0,0 +1,615 @@ +/* + * Copyright Nomura Research Institute, Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package jp.openstandia.connector.atlassian; + +import jp.openstandia.connector.atlassian.testutil.AbstractTest; +import org.identityconnectors.framework.common.exceptions.AlreadyExistsException; +import org.identityconnectors.framework.common.exceptions.UnknownUidException; +import org.identityconnectors.framework.common.objects.*; +import org.identityconnectors.framework.common.objects.filter.ContainsAllValuesFilter; +import org.identityconnectors.framework.common.objects.filter.FilterBuilder; +import org.junit.jupiter.api.Test; + +import java.util.*; +import java.util.concurrent.atomic.AtomicReference; + +import static jp.openstandia.connector.atlassian.AtlassianGuardGroupHandler.GROUP_OBJECT_CLASS; +import static org.junit.jupiter.api.Assertions.*; + +class GroupTest extends AbstractTest { + + @Test + void addGroup() { + // Given + String groupId = "1"; + String displayName = "foo"; + String member1 = "a1074ce4-b7e0-4454-975e-37ca2c1e8936"; + + Set attrs = new HashSet<>(); + attrs.add(new Name(displayName)); + attrs.add(AttributeBuilder.build("members.User.value", member1)); + + AtomicReference created = new AtomicReference<>(); + mockClient.createGroup = ((g) -> { + created.set(g); + + return new Uid(groupId, new Name(displayName)); + }); + mockClient.getGroupByName = ((name) -> { + return null; + }); + + // When + Uid uid = connector.create(GROUP_OBJECT_CLASS, attrs, new OperationOptionsBuilder().build()); + + // Then + assertEquals(groupId, uid.getUidValue()); + assertEquals(displayName, uid.getNameHintValue()); + + AtlassianGuardGroupModel newGroup = created.get(); + assertEquals(displayName, newGroup.displayName); + assertNotNull(newGroup.members); + assertEquals(1, newGroup.members.size()); + assertEquals(member1, newGroup.members.get(0).value); + } + + @Test + void addGroupButAlreadyExists() { + // Given + String groupId = "1"; + String displayName = "foo"; + + Set attrs = new HashSet<>(); + attrs.add(new Name(displayName)); + + mockClient.getGroupByName = ((name) -> { + // With default connector configuration, unique check displayName is enabled + throw new AlreadyExistsException(); + }); + + // When + Throwable expect = null; + try { + Uid uid = connector.create(GROUP_OBJECT_CLASS, attrs, new OperationOptionsBuilder().build()); + } catch (Throwable t) { + expect = t; + } + + // Then + assertNotNull(expect); + assertTrue(expect instanceof AlreadyExistsException); + } + + @Test + void updateGroup() { + // Given + String currentId = "1"; + String currentDisplayName = "foo"; + + String displayName = "bar"; + + Set modifications = new HashSet<>(); + modifications.add(AttributeDeltaBuilder.build(Name.NAME, displayName)); + + AtomicReference targetUid1 = new AtomicReference<>(); + AtomicReference updated = new AtomicReference<>(); + mockClient.patchGroup = ((u, operation) -> { + targetUid1.set(u); + updated.set(operation); + }); + + // When + Set affected = connector.updateDelta(GROUP_OBJECT_CLASS, new Uid(currentId, new Name(currentDisplayName)), modifications, new OperationOptionsBuilder().build()); + + // Then + assertNull(affected); + + assertEquals(currentId, targetUid1.get().getUidValue()); + assertEquals(currentDisplayName, targetUid1.get().getNameHintValue()); + + PatchOperationsModel operation = updated.get(); + assertNotNull(operation.operations); + assertTrue(operation.operations.stream().anyMatch(op -> op.op.equals("replace") && op.path.equals("displayName") && op.value.equals(displayName))); + } + + @Test + void updateGroupMembers() { + // Given + String currentId = "1"; + String currentDisplayName = "foo"; + + String memberAdd1 = "a1074ce4-b7e0-4454-975e-37ca2c1e8936"; + String memberRemove2 = "550e8400-e29b-41d4-a716-446655440000"; + + Set modifications = new HashSet<>(); + modifications.add(AttributeDeltaBuilder.build("members.User.value", Collections.singletonList(memberAdd1), Collections.singletonList(memberRemove2))); + + AtomicReference targetUid1 = new AtomicReference<>(); + AtomicReference updated = new AtomicReference<>(); + mockClient.patchGroup = ((u, operation) -> { + targetUid1.set(u); + updated.set(operation); + }); + + // When + Set affected = connector.updateDelta(GROUP_OBJECT_CLASS, new Uid(currentId, new Name(currentDisplayName)), modifications, new OperationOptionsBuilder().build()); + + // Then + assertNull(affected); + + PatchOperationsModel operation = updated.get(); + assertNotNull(operation.operations); + assertEquals(2, operation.operations.size()); + assertTrue(operation.operations.stream().anyMatch(op -> op.op.equals("add") && op.path.equals("members") + && op.value instanceof List && ((List) op.value).size() == 1 + && ((List) op.value).get(0).value.equals(memberAdd1))); + assertTrue(operation.operations.stream().anyMatch(op -> op.op.equals("remove") && op.path.equals("members") + && op.value instanceof List && ((List) op.value).size() == 1 + && ((List) op.value).get(0).value.equals(memberRemove2))); + } + + @Test + void updateGroupMembersWithMultiple() { + // Given + String currentId = "1"; + String currentDisplayName = "foo"; + + String memberAdd1 = "a1074ce4-b7e0-4454-975e-37ca2c1e8936"; + String memberAdd2 = "550e8400-e29b-41d4-a716-446655440000"; + String memberRemove1 = "176a78cf-4e1a-4f58-b79a-51f79acc50aa"; + String memberRemove2 = "822af548-1875-45d3-825e-e61b8576202a"; + + Set modifications = new HashSet<>(); + modifications.add(AttributeDeltaBuilder.build("members.User.value", Arrays.asList(memberAdd1, memberAdd2), Arrays.asList(memberRemove1, memberRemove2))); + + AtomicReference targetUid1 = new AtomicReference<>(); + AtomicReference updated = new AtomicReference<>(); + mockClient.patchGroup = ((u, operation) -> { + targetUid1.set(u); + updated.set(operation); + }); + + // When + Set affected = connector.updateDelta(GROUP_OBJECT_CLASS, new Uid(currentId, new Name(currentDisplayName)), modifications, new OperationOptionsBuilder().build()); + + // Then + assertNull(affected); + + PatchOperationsModel operation = updated.get(); + assertNotNull(operation.operations); + assertEquals(2, operation.operations.size()); + assertTrue(operation.operations.stream().anyMatch(op -> op.op.equals("add") && op.path.equals("members") + && op.value instanceof List && ((List) op.value).size() == 2 + && ((List) op.value).get(0).value.equals(memberAdd1))); + assertTrue(operation.operations.stream().anyMatch(op -> op.op.equals("add") && op.path.equals("members") + && op.value instanceof List && ((List) op.value).size() == 2 + && ((List) op.value).get(1).value.equals(memberAdd2))); + + assertTrue(operation.operations.stream().anyMatch(op -> op.op.equals("remove") && op.path.equals("members") + && op.value instanceof List && ((List) op.value).size() == 2 + && ((List) op.value).get(0).value.equals(memberRemove1))); + assertTrue(operation.operations.stream().anyMatch(op -> op.op.equals("remove") && op.path.equals("members") + && op.value instanceof List && ((List) op.value).size() == 2 + && ((List) op.value).get(1).value.equals(memberRemove2))); + } + + @Test + void updateGroupButNotFound() { + // Given + String currentId = "1"; + String currentDisplayName = "foo"; + + String displayName = "bar"; + + Set modifications = new HashSet<>(); + modifications.add(AttributeDeltaBuilder.build(Name.NAME, displayName)); + + mockClient.patchGroup = ((u, operation) -> { + throw new UnknownUidException(); + }); + + // When + Throwable expect = null; + try { + connector.updateDelta(GROUP_OBJECT_CLASS, new Uid(currentId, new Name(displayName)), modifications, new OperationOptionsBuilder().build()); + } catch (Throwable t) { + expect = t; + } + + // Then + assertNotNull(expect); + assertTrue(expect instanceof UnknownUidException); + } + + @Test + void getGroupByUid() { + // Given + String currentId = "1"; + String currentDisplayName = "foo"; + String currentMember1 = "a1074ce4-b7e0-4454-975e-37ca2c1e8936"; + + String createdDate = "2024-11-14T05:56:39.79755Z"; + String updatedDate = "2024-11-14T05:56:40.212208Z"; + + AtomicReference targetUid = new AtomicReference<>(); + mockClient.getGroupByUid = ((u) -> { + targetUid.set(u); + + AtlassianGuardGroupModel result = new AtlassianGuardGroupModel(); + result.id = currentId; + result.displayName = currentDisplayName; + result.members = new ArrayList<>(); + AtlassianGuardGroupModel.Member member = new AtlassianGuardGroupModel.Member(); + member.value = currentMember1; + member.type = "User"; + member.ref = "https://example.com/scim/directory/test/Users/" + currentMember1; + result.members.add(member); + result.meta = new AtlassianGuardGroupModel.Meta(); + result.meta.created = createdDate; + result.meta.lastModified = updatedDate; + return result; + }); + + // When + ConnectorObject result = connector.getObject(GROUP_OBJECT_CLASS, new Uid(currentId, new Name(currentDisplayName)), defaultGetOperation()); + + // Then + assertEquals(GROUP_OBJECT_CLASS, result.getObjectClass()); + assertEquals(currentId, result.getUid().getUidValue()); + assertEquals(currentDisplayName, result.getName().getNameValue()); + Attribute memberAttribute = result.getAttributeByName("members.User.value"); + assertNotNull(memberAttribute); + List members = memberAttribute.getValue(); + assertEquals(1, members.size()); + assertEquals(currentMember1, members.get(0)); + } + + @Test + void getGroupByName() { + // Given + String currentId = "1"; + String currentDisplayName = "foo"; + + String createdDate = "2024-11-14T05:56:39.79755Z"; + String updatedDate = "2024-11-14T05:56:40.212208Z"; + + AtomicReference targetName = new AtomicReference<>(); + mockClient.getGroupByName = ((name) -> { + targetName.set(name); + + AtlassianGuardGroupModel result = new AtlassianGuardGroupModel(); + result.id = currentId; + result.displayName = currentDisplayName; + result.members = new ArrayList<>(); + AtlassianGuardGroupModel.Member member = new AtlassianGuardGroupModel.Member(); + result.members.add(member); + result.meta = new AtlassianGuardGroupModel.Meta(); + result.meta.created = createdDate; + result.meta.lastModified = updatedDate; + return result; + }); + + // When + List results = new ArrayList<>(); + ResultsHandler handler = connectorObject -> { + results.add(connectorObject); + return true; + }; + connector.search(GROUP_OBJECT_CLASS, FilterBuilder.equalTo(new Name(currentDisplayName)), handler, defaultSearchOperation()); + + // Then + assertEquals(1, results.size()); + ConnectorObject result = results.get(0); + assertEquals(currentId, result.getUid().getUidValue()); + assertEquals(currentDisplayName, result.getName().getNameValue()); + } + + @Test + void getGroups() { + // Given + String currentId = "1"; + String currentDisplayName = "foo"; + + String createdDate = "2024-11-14T05:56:39.79755Z"; + String updatedDate = "2024-11-14T05:56:40.212208Z"; + + AtomicReference targetPageSize = new AtomicReference<>(); + AtomicReference targetOffset = new AtomicReference<>(); + mockClient.getGroups = ((h, size, offset) -> { + targetPageSize.set(size); + targetOffset.set(offset); + + AtlassianGuardGroupModel result = new AtlassianGuardGroupModel(); + result.id = currentId; + result.displayName = currentDisplayName; + result.meta = new AtlassianGuardGroupModel.Meta(); + result.meta.created = createdDate; + result.meta.lastModified = updatedDate; + h.handle(result); + + return 1; + }); + + // When + List results = new ArrayList<>(); + ResultsHandler handler = connectorObject -> { + results.add(connectorObject); + return true; + }; + connector.search(GROUP_OBJECT_CLASS, null, handler, defaultSearchOperation()); + + // Then + assertEquals(1, results.size()); + ConnectorObject result = results.get(0); + assertEquals(GROUP_OBJECT_CLASS, result.getObjectClass()); + assertEquals(currentId, result.getUid().getUidValue()); + assertEquals(currentDisplayName, result.getName().getNameValue()); + + assertEquals(20, targetPageSize.get(), "Not page size in the operation option"); + assertEquals(1, targetOffset.get()); + } + + @Test + void getGroupsZero() { + // Given + AtomicReference targetPageSize = new AtomicReference<>(); + AtomicReference targetOffset = new AtomicReference<>(); + mockClient.getGroups = ((h, size, offset) -> { + targetPageSize.set(size); + targetOffset.set(offset); + + return 0; + }); + + // When + List results = new ArrayList<>(); + ResultsHandler handler = connectorObject -> { + results.add(connectorObject); + return true; + }; + connector.search(GROUP_OBJECT_CLASS, null, handler, defaultSearchOperation()); + + // Then + assertEquals(0, results.size()); + assertEquals(20, targetPageSize.get(), "Not default page size in the configuration"); + assertEquals(1, targetOffset.get()); + } + + @Test + void getGroupsTwo() { + // Given + AtomicReference targetPageSize = new AtomicReference<>(); + AtomicReference targetOffset = new AtomicReference<>(); + mockClient.getGroups = ((h, size, offset) -> { + targetPageSize.set(size); + targetOffset.set(offset); + + AtlassianGuardGroupModel result = new AtlassianGuardGroupModel(); + result.id = "1"; + result.displayName = "a"; + result.meta = new AtlassianGuardGroupModel.Meta(); + result.meta.created = "2024-11-14T05:56:39.79755Z"; + result.meta.lastModified = "2024-11-14T05:56:40.212208Z"; + h.handle(result); + + result = new AtlassianGuardGroupModel(); + result.id = "2"; + result.displayName = "b"; + result.meta = new AtlassianGuardGroupModel.Meta(); + result.meta.created = "2024-11-14T05:56:39.79755Z"; + result.meta.lastModified = "2024-11-14T05:56:40.212208Z"; + h.handle(result); + + return 2; + }); + + // When + List results = new ArrayList<>(); + ResultsHandler handler = connectorObject -> { + results.add(connectorObject); + return true; + }; + connector.search(GROUP_OBJECT_CLASS, null, handler, defaultSearchOperation()); + + // Then + assertEquals(2, results.size()); + + ConnectorObject result = results.get(0); + assertEquals(GROUP_OBJECT_CLASS, result.getObjectClass()); + assertEquals("1", result.getUid().getUidValue()); + assertEquals("a", result.getName().getNameValue()); + + result = results.get(1); + assertEquals(GROUP_OBJECT_CLASS, result.getObjectClass()); + assertEquals("2", result.getUid().getUidValue()); + assertEquals("b", result.getName().getNameValue()); + + assertEquals(20, targetPageSize.get(), "Not default page size in the configuration"); + assertEquals(1, targetOffset.get()); + } + + @Test + void getGroupsByMembers() { + // Given + AtomicReference targetPageSize = new AtomicReference<>(); + AtomicReference targetOffset = new AtomicReference<>(); + mockClient.getGroups = ((h, size, offset) -> { + targetPageSize.set(size); + targetOffset.set(offset); + + // 1 + AtlassianGuardGroupModel result = new AtlassianGuardGroupModel(); + result.id = "1"; + result.displayName = "a"; + + result.members = new ArrayList<>(); + AtlassianGuardGroupModel.Member member = new AtlassianGuardGroupModel.Member(); + member.value = "user001"; + member.type = "User"; + member.ref = "https://example.com/scim/directory/test/Users/user001"; + result.members.add(member); + + member = new AtlassianGuardGroupModel.Member(); + member.value = "user002"; + member.type = "User"; + member.ref = "https://example.com/scim/directory/test/Users/user002"; + result.members.add(member); + + member = new AtlassianGuardGroupModel.Member(); + member.value = "user003"; + member.type = "User"; + member.ref = "https://example.com/scim/directory/test/Users/user003"; + result.members.add(member); + + result.meta = new AtlassianGuardGroupModel.Meta(); + result.meta.created = "2024-11-14T05:56:39.79755Z"; + result.meta.lastModified = "2024-11-14T05:56:40.212208Z"; + h.handle(result); + + // 2 + result = new AtlassianGuardGroupModel(); + result.id = "2"; + result.displayName = "b"; + + result.members = new ArrayList<>(); + member = new AtlassianGuardGroupModel.Member(); + member.value = "user001"; + member.type = "User"; + member.ref = "https://example.com/scim/directory/test/Users/user001"; + result.members.add(member); + + result.meta = new AtlassianGuardGroupModel.Meta(); + result.meta.created = "2024-11-14T05:56:39.79755Z"; + result.meta.lastModified = "2024-11-14T05:56:40.212208Z"; + h.handle(result); + + // 3 + result = new AtlassianGuardGroupModel(); + result.id = "3"; + result.displayName = "c"; + + result.members = new ArrayList<>(); + member = new AtlassianGuardGroupModel.Member(); + member.value = "user003"; + member.type = "User"; + member.ref = "https://example.com/scim/directory/test/Users/user003"; + result.members.add(member); + + result.meta = new AtlassianGuardGroupModel.Meta(); + result.meta.created = "2024-11-14T05:56:39.79755Z"; + result.meta.lastModified = "2024-11-14T05:56:40.212208Z"; + h.handle(result); + + // 4 + result = new AtlassianGuardGroupModel(); + result.id = "4"; + result.displayName = "d"; + + result.members = new ArrayList<>(); + + result.meta = new AtlassianGuardGroupModel.Meta(); + result.meta.created = "2024-11-14T05:56:39.79755Z"; + result.meta.lastModified = "2024-11-14T05:56:40.212208Z"; + h.handle(result); + + return 4; + }); + + // When + List results = new ArrayList<>(); + ResultsHandler handler = connectorObject -> { + results.add(connectorObject); + return true; + }; + Attribute user001 = AttributeBuilder.build("members.User.value", Collections.singletonList("user001")); + connector.search(GROUP_OBJECT_CLASS, new ContainsAllValuesFilter(user001), handler, defaultSearchOperation()); + + // Then + assertEquals(2, results.size()); + + ConnectorObject result = results.get(0); + assertEquals(GROUP_OBJECT_CLASS, result.getObjectClass()); + assertEquals("1", result.getUid().getUidValue()); + assertEquals("a", result.getName().getNameValue()); + + result = results.get(1); + assertEquals(GROUP_OBJECT_CLASS, result.getObjectClass()); + assertEquals("2", result.getUid().getUidValue()); + assertEquals("b", result.getName().getNameValue()); + + assertEquals(20, targetPageSize.get(), "Not default page size in the configuration"); + assertEquals(1, targetOffset.get()); + + // When + List results2 = new ArrayList<>(); + ResultsHandler handler2 = connectorObject -> { + results2.add(connectorObject); + return true; + }; + Attribute user001AndUser002 = AttributeBuilder.build("members.User.value", Arrays.asList("user001", "user002")); + connector.search(GROUP_OBJECT_CLASS, new ContainsAllValuesFilter(user001AndUser002), handler2, defaultSearchOperation()); + + // Then + assertEquals(1, results2.size()); + + result = results2.get(0); + assertEquals(GROUP_OBJECT_CLASS, result.getObjectClass()); + assertEquals("1", result.getUid().getUidValue()); + assertEquals("a", result.getName().getNameValue()); + } + + @Test + void deleteGroup() { + // Given + String currentId = "foo"; + String currentDisplayName = "foo"; + + AtomicReference deleted = new AtomicReference<>(); + mockClient.deleteGroup = ((u) -> { + deleted.set(u); + }); + + // When + connector.delete(GROUP_OBJECT_CLASS, new Uid(currentId, new Name(currentDisplayName)), new OperationOptionsBuilder().build()); + + // Then + assertEquals(currentId, deleted.get().getUidValue()); + assertEquals(currentDisplayName, deleted.get().getNameHintValue()); + } + + @Test + void deleteGroupButNotFound() { + // Given + String currentId = "1"; + String currentDisplayName = "foo"; + + mockClient.deleteGroup = ((u) -> { + throw new UnknownUidException(); + }); + + // When + Throwable expect = null; + try { + connector.delete(GROUP_OBJECT_CLASS, new Uid(currentId, new Name(currentDisplayName)), new OperationOptionsBuilder().build()); + } catch (Throwable t) { + expect = t; + } + + // Then + assertNotNull(expect); + assertTrue(expect instanceof UnknownUidException); + } +} diff --git a/src/test/java/jp/openstandia/connector/atlassian/SchemaTest.java b/src/test/java/jp/openstandia/connector/atlassian/SchemaTest.java new file mode 100644 index 0000000..b9c0274 --- /dev/null +++ b/src/test/java/jp/openstandia/connector/atlassian/SchemaTest.java @@ -0,0 +1,98 @@ +/* + * Copyright Nomura Research Institute, Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package jp.openstandia.connector.atlassian; + +import jp.openstandia.connector.atlassian.testutil.AbstractTest; +import org.identityconnectors.framework.common.objects.*; +import org.junit.jupiter.api.Test; + +import java.util.Optional; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.*; + +class SchemaTest extends AbstractTest { + + @Test + void schema() { + Schema schema = connector.schema(); + + assertNotNull(schema); + assertEquals(2, schema.getObjectClassInfo().size()); + } + + @Test + void user() { + Schema schema = connector.schema(); + + Optional user = schema.getObjectClassInfo().stream().filter(o -> o.is("User")).findFirst(); + + assertTrue(user.isPresent()); + + ObjectClassInfo userSchema = user.get(); + Set attributeInfo = userSchema.getAttributeInfo(); + + assertEquals(20, attributeInfo.size()); + assertAttributeInfo(attributeInfo, Uid.NAME); + assertAttributeInfo(attributeInfo, Name.NAME); + assertAttributeInfo(attributeInfo, "name.formatted"); + assertAttributeInfo(attributeInfo, "name.familyName"); + assertAttributeInfo(attributeInfo, "name.givenName"); + assertAttributeInfo(attributeInfo, "name.middleName"); + assertAttributeInfo(attributeInfo, "name.honorificPrefix"); + assertAttributeInfo(attributeInfo, "name.honorificSuffix"); + assertAttributeInfo(attributeInfo, "displayName"); + assertAttributeInfo(attributeInfo, "nickName"); + assertAttributeInfo(attributeInfo, "title"); + assertAttributeInfo(attributeInfo, "preferredLanguage"); + assertAttributeInfo(attributeInfo, "timezone"); + assertAttributeInfo(attributeInfo, OperationalAttributes.ENABLE_NAME); + assertAttributeInfo(attributeInfo, "primaryEmail"); + assertAttributeInfo(attributeInfo, "primaryPhoneNumber"); + assertAttributeInfo(attributeInfo, "groups", true); + assertAttributeInfo(attributeInfo, "meta.created"); + assertAttributeInfo(attributeInfo, "meta.lastModified"); + } + + @Test + void group() { + Schema schema = connector.schema(); + + Optional group = schema.getObjectClassInfo().stream().filter(o -> o.is("Group")).findFirst(); + + assertTrue(group.isPresent()); + + ObjectClassInfo groupSchema = group.get(); + Set attributeInfo = groupSchema.getAttributeInfo(); + + assertEquals(5, attributeInfo.size()); + assertAttributeInfo(attributeInfo, Uid.NAME); + assertAttributeInfo(attributeInfo, Name.NAME); + assertAttributeInfo(attributeInfo, "members.User.value", true); + assertAttributeInfo(attributeInfo, "meta.created"); + assertAttributeInfo(attributeInfo, "meta.lastModified"); + } + + protected void assertAttributeInfo(Set info, String attrName) { + assertAttributeInfo(info, attrName, false); + } + + protected void assertAttributeInfo(Set info, String attrName, boolean isMultiple) { + Optional attributeInfo = info.stream().filter(x -> x.is(attrName)).findFirst(); + assertTrue(attributeInfo.isPresent(), attrName); + assertEquals(isMultiple, attributeInfo.get().isMultiValued(), "Unexpected multiValued of " + attrName); + } +} diff --git a/src/test/java/jp/openstandia/connector/atlassian/TestTest.java b/src/test/java/jp/openstandia/connector/atlassian/TestTest.java new file mode 100644 index 0000000..1c543b4 --- /dev/null +++ b/src/test/java/jp/openstandia/connector/atlassian/TestTest.java @@ -0,0 +1,27 @@ +/* + * Copyright Nomura Research Institute, Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package jp.openstandia.connector.atlassian; + +import jp.openstandia.connector.atlassian.testutil.AbstractTest; +import org.junit.jupiter.api.Test; + +class TestTest extends AbstractTest { + + @Test + void test() { + connector.test(); + } +} diff --git a/src/test/java/jp/openstandia/connector/atlassian/UserTest.java b/src/test/java/jp/openstandia/connector/atlassian/UserTest.java new file mode 100644 index 0000000..641d51e --- /dev/null +++ b/src/test/java/jp/openstandia/connector/atlassian/UserTest.java @@ -0,0 +1,835 @@ +/* + * Copyright Nomura Research Institute, Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package jp.openstandia.connector.atlassian; + +import jp.openstandia.connector.atlassian.testutil.AbstractTest; +import org.identityconnectors.framework.api.ConnectorFacade; +import org.identityconnectors.framework.common.exceptions.AlreadyExistsException; +import org.identityconnectors.framework.common.exceptions.UnknownUidException; +import org.identityconnectors.framework.common.objects.*; +import org.identityconnectors.framework.common.objects.filter.FilterBuilder; +import org.identityconnectors.framework.spi.SearchResultsHandler; +import org.junit.jupiter.api.Test; + +import java.util.*; +import java.util.concurrent.atomic.AtomicReference; + +import static jp.openstandia.connector.atlassian.AtlassianGuardUserHandler.USER_OBJECT_CLASS; +import static jp.openstandia.connector.util.Utils.toZoneDateTimeForISO8601OffsetDateTime; +import static org.junit.jupiter.api.Assertions.*; + +class UserTest extends AbstractTest { + + @Test + void addUser() { + // Given + String userId = "12345"; + String userName = "foo"; + String formatted = "Foo Bar"; + String familyName = "Bar"; + String givenName = "Foo"; + String middleName = "Hoge"; + String honorificPrefix = "Dr"; + String honorificSuffix = "Jr"; + String displayName = "Foo Hoge Bar"; + String nickName = "foobar"; + String title = "CEO"; + String preferredLanguage = "ja_JP"; + String timezone = "Asia/Tokyo"; + String primaryEmail = "foo@example.com"; + String primaryPhoneNumber = "012-3456-7890"; + String primaryPhoneNumberType = "work"; + boolean active = true; + + Set attrs = new HashSet<>(); + attrs.add(new Name(userName)); + attrs.add(AttributeBuilder.build("name.formatted", formatted)); + attrs.add(AttributeBuilder.build("name.familyName", familyName)); + attrs.add(AttributeBuilder.build("name.givenName", givenName)); + attrs.add(AttributeBuilder.build("name.middleName", middleName)); + attrs.add(AttributeBuilder.build("name.honorificPrefix", honorificPrefix)); + attrs.add(AttributeBuilder.build("name.honorificSuffix", honorificSuffix)); + attrs.add(AttributeBuilder.build("displayName", displayName)); + attrs.add(AttributeBuilder.build("nickName", nickName)); + attrs.add(AttributeBuilder.build("title", title)); + attrs.add(AttributeBuilder.build("preferredLanguage", preferredLanguage)); + attrs.add(AttributeBuilder.build("timezone", timezone)); + attrs.add(AttributeBuilder.build("primaryEmail", primaryEmail)); + attrs.add(AttributeBuilder.build("primaryPhoneNumber", primaryPhoneNumber + "/" + primaryPhoneNumberType)); + attrs.add(AttributeBuilder.buildEnabled(active)); + + AtomicReference created = new AtomicReference<>(); + mockClient.createUser = ((user) -> { + created.set(user); + + return new Uid(userId, new Name(userName)); + }); + + // When + Uid uid = connector.create(USER_OBJECT_CLASS, attrs, new OperationOptionsBuilder().build()); + + // Then + assertEquals(userId, uid.getUidValue()); + assertEquals(userName, uid.getNameHintValue()); + + AtlassianGuardUserModel newUser = created.get(); + assertEquals(userName, newUser.userName); + assertEquals(formatted, newUser.name.formatted); + assertEquals(familyName, newUser.name.familyName); + assertEquals(givenName, newUser.name.givenName); + assertEquals(middleName, newUser.name.middleName); + assertEquals(honorificPrefix, newUser.name.honorificPrefix); + assertEquals(honorificSuffix, newUser.name.honorificSuffix); + assertEquals(displayName, newUser.displayName); + assertEquals(nickName, newUser.nickName); + assertEquals(title, newUser.title); + assertEquals(preferredLanguage, newUser.preferredLanguage); + assertEquals(timezone, newUser.timezone); + assertEquals(1, newUser.emails.size()); + assertEquals(primaryEmail, newUser.emails.get(0).value); + assertTrue(newUser.emails.get(0).primary); + assertEquals(1, newUser.phoneNumbers.size()); + assertEquals(primaryPhoneNumber, newUser.phoneNumbers.get(0).value); + assertEquals(primaryPhoneNumberType, newUser.phoneNumbers.get(0).type); + assertTrue(newUser.phoneNumbers.get(0).primary); + } + + @Test + void addUserButAlreadyExists() { + // Given + String userName = "foo"; + + Set attrs = new HashSet<>(); + attrs.add(new Name(userName)); + + mockClient.createUser = ((user) -> { + throw new AlreadyExistsException(""); + }); + + // When + Throwable expect = null; + try { + Uid uid = connector.create(USER_OBJECT_CLASS, attrs, new OperationOptionsBuilder().build()); + } catch (Throwable t) { + expect = t; + } + + // Then + assertNotNull(expect); + assertTrue(expect instanceof AlreadyExistsException); + } + + @Test + void updateUser() { + // Given + String currentUserName = "hoge"; + + String userId = "12345"; + String userName = "foo"; + String formatted = "Foo Bar"; + String familyName = "Bar"; + String givenName = "Foo"; + String middleName = "Hoge"; + String honorificPrefix = "Dr"; + String honorificSuffix = "Jr"; + String displayName = "Foo Hoge Bar"; + String nickName = "foobar"; + String title = "CEO"; + String preferredLanguage = "ja_JP"; + String timezone = "Asia/Tokyo"; + String primaryEmail = "foo@example.com"; + String primaryPhoneNumber = "012-3456-7890"; + String primaryPhoneNumberType = "work"; + boolean active = false; + + Set modifications = new LinkedHashSet<>(); + modifications.add(AttributeDeltaBuilder.build(Name.NAME, userName)); + modifications.add(AttributeDeltaBuilder.build("name.formatted", formatted)); + modifications.add(AttributeDeltaBuilder.build("name.familyName", familyName)); + modifications.add(AttributeDeltaBuilder.build("name.givenName", givenName)); + modifications.add(AttributeDeltaBuilder.build("name.middleName", middleName)); + modifications.add(AttributeDeltaBuilder.build("name.honorificPrefix", honorificPrefix)); + modifications.add(AttributeDeltaBuilder.build("name.honorificSuffix", honorificSuffix)); + modifications.add(AttributeDeltaBuilder.build("displayName", displayName)); + modifications.add(AttributeDeltaBuilder.build("nickName", nickName)); + modifications.add(AttributeDeltaBuilder.build("title", title)); + modifications.add(AttributeDeltaBuilder.build("preferredLanguage", preferredLanguage)); + modifications.add(AttributeDeltaBuilder.build("timezone", timezone)); + modifications.add(AttributeDeltaBuilder.build("primaryEmail", primaryEmail)); + modifications.add(AttributeDeltaBuilder.build("primaryPhoneNumber", primaryPhoneNumber + "/" + primaryPhoneNumberType)); + modifications.add(AttributeDeltaBuilder.buildEnabled(active)); + + AtomicReference targetUid = new AtomicReference<>(); + AtomicReference updated = new AtomicReference<>(); + mockClient.patchUser = ((u, operations) -> { + targetUid.set(u); + updated.set(operations); + }); + + // When + Set affected = connector.updateDelta(USER_OBJECT_CLASS, new Uid(userId, new Name(currentUserName)), modifications, new OperationOptionsBuilder().build()); + + // Then + assertNull(affected); + + assertEquals(userId, targetUid.get().getUidValue()); + assertEquals(currentUserName, targetUid.get().getNameHintValue()); + + PatchOperationsModel operation = updated.get(); + assertNotNull(operation.operations); + assertTrue(operation.operations.stream().anyMatch(op -> op.path.equals("userName") && op.value.equals(userName))); + assertTrue(operation.operations.stream().anyMatch(op -> op.path.equals("name.formatted") && op.value.equals(formatted))); + assertTrue(operation.operations.stream().anyMatch(op -> op.path.equals("name.familyName") && op.value.equals(familyName))); + assertTrue(operation.operations.stream().anyMatch(op -> op.path.equals("name.givenName") && op.value.equals(givenName))); + assertTrue(operation.operations.stream().anyMatch(op -> op.path.equals("name.middleName") && op.value.equals(middleName))); + assertTrue(operation.operations.stream().anyMatch(op -> op.path.equals("name.honorificPrefix") && op.value.equals(honorificPrefix))); + assertTrue(operation.operations.stream().anyMatch(op -> op.path.equals("name.honorificSuffix") && op.value.equals(honorificSuffix))); + assertTrue(operation.operations.stream().anyMatch(op -> op.path.equals("displayName") && op.value.equals(displayName))); + assertTrue(operation.operations.stream().anyMatch(op -> op.path.equals("nickName") && op.value.equals(nickName))); + assertTrue(operation.operations.stream().anyMatch(op -> op.path.equals("title") && op.value.equals(title))); + assertTrue(operation.operations.stream().anyMatch(op -> op.path.equals("preferredLanguage") && op.value.equals(preferredLanguage))); + assertTrue(operation.operations.stream().anyMatch(op -> op.path.equals("timezone") && op.value.equals(timezone))); + assertTrue(operation.operations.stream().anyMatch(op -> op.path.equals("emails") && ((List) op.value).size() == 1)); + assertTrue(operation.operations.stream().anyMatch(op -> op.path.equals("emails") && ((List) op.value).get(0).value.equals(primaryEmail))); + assertTrue(operation.operations.stream().anyMatch(op -> op.path.equals("emails") && ((List) op.value).get(0).primary.equals(true))); + assertTrue(operation.operations.stream().anyMatch(op -> op.path.equals("phoneNumbers") && ((List) op.value).size() == 1)); + assertTrue(operation.operations.stream().anyMatch(op -> op.path.equals("phoneNumbers") && ((List) op.value).get(0).value.equals(primaryPhoneNumber))); + assertTrue(operation.operations.stream().anyMatch(op -> op.path.equals("phoneNumbers") && ((List) op.value).get(0).primary.equals(true))); + assertTrue(operation.operations.stream().anyMatch(op -> op.path.equals("phoneNumbers") && ((List) op.value).get(0).type.equals(primaryPhoneNumberType))); + assertTrue(operation.operations.stream().anyMatch(op -> op.path.equals("active") && op.value.equals(active))); + } + + @Test + void updateUserWithNoValues() { + ConnectorFacade connector = newFacade(configuration); + + // Given + String currentUserName = "foo"; + + String userId = "12345"; + String userName = "foo"; + + Set modifications = new HashSet<>(); + // IDM sets empty list to remove the single value + modifications.add(AttributeDeltaBuilder.build("name.formatted", Collections.emptyList())); + modifications.add(AttributeDeltaBuilder.build("name.familyName", Collections.emptyList())); + modifications.add(AttributeDeltaBuilder.build("name.givenName", Collections.emptyList())); + + AtomicReference targetUid = new AtomicReference<>(); + AtomicReference updated = new AtomicReference<>(); + mockClient.patchUser = ((u, operations) -> { + targetUid.set(u); + updated.set(operations); + }); + + // When + Set affected = connector.updateDelta(USER_OBJECT_CLASS, new Uid(userId, new Name(currentUserName)), modifications, new OperationOptionsBuilder().build()); + + // Then + assertNull(affected); + + assertEquals(userId, targetUid.get().getUidValue()); + assertEquals(userName, targetUid.get().getNameHintValue()); + + PatchOperationsModel operation = updated.get(); + assertNotNull(operation.operations); + // Atlassian Guard API treats empty string as removing the value, but name.* attributes are not supported removing with empty string + assertTrue(operation.operations.stream().anyMatch(op -> op.op.equals("replace") && op.path.equals("name.formatted") && op.value.equals(""))); + assertTrue(operation.operations.stream().anyMatch(op -> op.op.equals("replace") & op.path.equals("name.familyName") && op.value.equals(""))); + assertTrue(operation.operations.stream().anyMatch(op -> op.op.equals("replace") & op.path.equals("name.givenName") && op.value.equals(""))); + } + + @Test + void updateUserButNotFound() { + // Given + String currentUserName = "foo"; + + String userId = "12345"; + String formatted = "Foo Bar"; + + Set modifications = new HashSet<>(); + modifications.add(AttributeDeltaBuilder.build("name.formatted", formatted)); + + mockClient.patchUser = ((u, operations) -> { + throw new UnknownUidException(); + }); + + // When + Throwable expect = null; + try { + connector.updateDelta(USER_OBJECT_CLASS, new Uid(userId, new Name(currentUserName)), modifications, new OperationOptionsBuilder().build()); + } catch (Throwable t) { + expect = t; + } + + // Then + assertNotNull(expect); + assertTrue(expect instanceof UnknownUidException); + } + + @Test + void getUserByUid() { + // Given + String userId = "12345"; + String userName = "foo"; + String formatted = "Foo Bar"; + String familyName = "Bar"; + String givenName = "Foo"; + String middleName = "Hoge"; + String honorificPrefix = "Dr"; + String honorificSuffix = "Jr"; + String displayName = "Foo Hoge Bar"; + String nickName = "foobar"; + String title = "CEO"; + String preferredLanguage = "ja_JP"; + String timezone = "Asia/Tokyo"; + String primaryEmail = "foo@example.com"; + String primaryPhoneNumber = "012-3456-7890"; + String primaryPhoneNumberType = "work"; + boolean active = false; + + String createdDate = "2024-11-14T05:56:39.79755Z"; + String updatedDate = "2024-11-14T05:56:40.212208Z"; + + AtomicReference targetUid = new AtomicReference<>(); + mockClient.getUserByUid = ((u) -> { + targetUid.set(u); + + AtlassianGuardUserModel result = new AtlassianGuardUserModel(); + result.id = userId; + result.userName = userName; + result.name = new AtlassianGuardUserModel.Name(); + result.name.formatted = formatted; + result.name.familyName = familyName; + result.name.givenName = givenName; + result.name.middleName = middleName; + result.name.honorificPrefix = honorificPrefix; + result.name.honorificSuffix = honorificSuffix; + result.displayName = displayName; + result.nickName = nickName; + result.title = title; + result.preferredLanguage = preferredLanguage; + result.timezone = timezone; + + List emails = new ArrayList<>(); + AtlassianGuardUserModel.Email email = new AtlassianGuardUserModel.Email(); + email.value = primaryEmail; + email.primary = true; + emails.add(email); + result.emails = emails; + + List phoneNumbers = new ArrayList<>(); + AtlassianGuardUserModel.PhoneNumber phoneNumber = new AtlassianGuardUserModel.PhoneNumber(); + phoneNumber.value = primaryPhoneNumber; + phoneNumber.primary = true; + phoneNumber.type = primaryPhoneNumberType; + phoneNumbers.add(phoneNumber); + result.phoneNumbers = phoneNumbers; + + result.active = active; + result.meta = new AtlassianGuardUserModel.Meta(); + result.meta.created = createdDate; + result.meta.lastModified = updatedDate; + + return result; + }); + AtomicReference targetName = new AtomicReference<>(); + AtomicReference targetPageSize = new AtomicReference<>(); + + // When + ConnectorObject result = connector.getObject(USER_OBJECT_CLASS, new Uid(userId, new Name(userName)), defaultGetOperation()); + + // Then + assertEquals(USER_OBJECT_CLASS, result.getObjectClass()); + assertEquals(userId, result.getUid().getUidValue()); + assertEquals(userName, result.getName().getNameValue()); + assertEquals(formatted, singleAttr(result, "name.formatted")); + assertEquals(familyName, singleAttr(result, "name.familyName")); + assertEquals(givenName, singleAttr(result, "name.givenName")); + assertEquals(middleName, singleAttr(result, "name.middleName")); + assertEquals(givenName, singleAttr(result, "name.givenName")); + assertEquals(honorificPrefix, singleAttr(result, "name.honorificPrefix")); + assertEquals(honorificSuffix, singleAttr(result, "name.honorificSuffix")); + assertEquals(displayName, singleAttr(result, "displayName")); + assertEquals(nickName, singleAttr(result, "nickName")); + assertEquals(title, singleAttr(result, "title")); + assertEquals(preferredLanguage, singleAttr(result, "preferredLanguage")); + assertEquals(timezone, singleAttr(result, "timezone")); + assertEquals(primaryEmail, singleAttr(result, "primaryEmail")); + assertEquals(primaryPhoneNumber + "/" + primaryPhoneNumberType, singleAttr(result, "primaryPhoneNumber")); + assertEquals(active, singleAttr(result, OperationalAttributes.ENABLE_NAME)); + assertEquals(toZoneDateTimeForISO8601OffsetDateTime(createdDate), singleAttr(result, "meta.created")); + assertEquals(toZoneDateTimeForISO8601OffsetDateTime(updatedDate), singleAttr(result, "meta.lastModified")); + + assertNull(targetName.get()); + assertNull(targetPageSize.get()); + } + + @Test + void getUserByUidWithEmpty() { + ConnectorFacade connector = newFacade(configuration); + + // Given + String userId = "12345"; + String userName = "foo"; + String createdDate = "2024-11-14T05:56:39.79755Z"; + String updatedDate = "2024-11-14T05:56:40.212208Z"; + + AtomicReference targetUid = new AtomicReference<>(); + mockClient.getUserByUid = ((u) -> { + targetUid.set(u); + + AtlassianGuardUserModel result = new AtlassianGuardUserModel(); + result.id = userId; + result.userName = userName; + result.name = new AtlassianGuardUserModel.Name(); // Empty name + result.meta = new AtlassianGuardUserModel.Meta(); + result.meta.created = createdDate; + result.meta.lastModified = updatedDate; + return result; + }); + + // When + ConnectorObject result = connector.getObject(USER_OBJECT_CLASS, new Uid(userId, new Name(userName)), defaultGetOperation()); + + // Then + assertEquals(USER_OBJECT_CLASS, result.getObjectClass()); + assertEquals(userId, result.getUid().getUidValue()); + assertEquals(userName, result.getName().getNameValue()); + assertNull(result.getAttributeByName("name.formatted")); + assertNull(result.getAttributeByName("name.familyName")); + assertNull(result.getAttributeByName("name.givenName")); + } + + @Test + void getUserByUidWithNotFound() { + ConnectorFacade connector = newFacade(configuration); + + // Given + String userId = "12345"; + String userName = "foo"; + + AtomicReference targetUid = new AtomicReference<>(); + mockClient.getUserByUid = ((u) -> { + targetUid.set(u); + return null; + }); + + // When + ConnectorObject result = connector.getObject(USER_OBJECT_CLASS, new Uid(userId, new Name(userName)), defaultGetOperation()); + + // Then + assertNull(result); + assertNotNull(targetUid.get()); + } + + @Test + void getUserByName() { + // Given + String userId = "12345"; + String userName = "foo"; + String formatted = "Foo Bar"; + String givenName = "Foo"; + String familyName = "Bar"; + String createdDate = "2024-11-14T05:56:39.79755Z"; + String updatedDate = "2024-11-14T05:56:40.212208Z"; + + AtomicReference targetName = new AtomicReference<>(); + mockClient.getUserByName = ((u) -> { + targetName.set(u); + + AtlassianGuardUserModel result = new AtlassianGuardUserModel(); + result.id = userId; + result.userName = userName; + result.name = new AtlassianGuardUserModel.Name(); + result.name.formatted = formatted; + result.name.givenName = givenName; + result.name.familyName = familyName; + result.meta = new AtlassianGuardUserModel.Meta(); + result.meta.created = createdDate; + result.meta.lastModified = updatedDate; + return result; + }); + + // When + List results = new ArrayList<>(); + ResultsHandler handler = connectorObject -> { + results.add(connectorObject); + return true; + }; + connector.search(USER_OBJECT_CLASS, FilterBuilder.equalTo(new Name(userName)), handler, defaultSearchOperation()); + + // Then + assertEquals(1, results.size()); + ConnectorObject result = results.get(0); + assertEquals(USER_OBJECT_CLASS, result.getObjectClass()); + assertEquals(userId, result.getUid().getUidValue()); + assertEquals(userName, result.getName().getNameValue()); + assertEquals(formatted, singleAttr(result, "name.formatted")); + assertEquals(givenName, singleAttr(result, "name.givenName")); + assertEquals(familyName, singleAttr(result, "name.familyName")); + assertEquals(toZoneDateTimeForISO8601OffsetDateTime(createdDate), singleAttr(result, "meta.created")); + assertEquals(toZoneDateTimeForISO8601OffsetDateTime(updatedDate), singleAttr(result, "meta.lastModified")); + } + + @Test + void getUsers() { + // Given + String userId = "12345"; + String userName = "foo"; + String formatted = "Foo Bar"; + String createdDate = "2024-11-14T05:56:39.79755Z"; + String updatedDate = "2024-11-14T05:56:40.212208Z"; + + AtomicReference targetPageSize = new AtomicReference<>(); + AtomicReference targetOffset = new AtomicReference<>(); + mockClient.getUsers = ((h, size, offset) -> { + targetPageSize.set(size); + targetOffset.set(offset); + + AtlassianGuardUserModel result = new AtlassianGuardUserModel(); + result.id = userId; + result.userName = userName; + result.name = new AtlassianGuardUserModel.Name(); + result.name.formatted = formatted; + result.meta = new AtlassianGuardUserModel.Meta(); + result.meta.created = createdDate; + result.meta.lastModified = updatedDate; + h.handle(result); + + return 1; + }); + + // When + List results = new ArrayList<>(); + ResultsHandler handler = connectorObject -> { + results.add(connectorObject); + return true; + }; + connector.search(USER_OBJECT_CLASS, null, handler, defaultSearchOperation()); + + // Then + assertEquals(1, results.size()); + ConnectorObject result = results.get(0); + assertEquals(USER_OBJECT_CLASS, result.getObjectClass()); + assertEquals(userId, result.getUid().getUidValue()); + assertEquals(userName, result.getName().getNameValue()); + assertEquals(formatted, singleAttr(result, "name.formatted")); + assertEquals(toZoneDateTimeForISO8601OffsetDateTime(createdDate), singleAttr(result, "meta.created")); + assertEquals(toZoneDateTimeForISO8601OffsetDateTime(updatedDate), singleAttr(result, "meta.lastModified")); + + assertEquals(20, targetPageSize.get(), "Not page size in the operation option"); + assertEquals(1, targetOffset.get()); + } + + @Test + void getUsersWithGroups() { + // Given + String userId = "12345"; + String userName = "foo"; + String formatted = "Foo Bar"; + String createdDate = "2024-11-14T05:56:39.79755Z"; + String updatedDate = "2024-11-14T05:56:40.212208Z"; + String currentGroup1 = "d138e7b8-fd26-45b2-bff9-34d11b29aff1"; + String currentGroup2 = "592af241-65c3-4dc3-83b7-f3f82f6c28bd"; + + AtomicReference targetPageSize = new AtomicReference<>(); + AtomicReference targetOffset = new AtomicReference<>(); + mockClient.getUsers = ((h, size, offset) -> { + targetPageSize.set(size); + targetOffset.set(offset); + + AtlassianGuardUserModel result = new AtlassianGuardUserModel(); + result.id = userId; + result.userName = userName; + result.name = new AtlassianGuardUserModel.Name(); + result.name.formatted = formatted; + result.meta = new AtlassianGuardUserModel.Meta(); + result.meta.created = createdDate; + result.meta.lastModified = updatedDate; + + + AtlassianGuardUserModel.Group group1 = new AtlassianGuardUserModel.Group(); + group1.value = currentGroup1; + group1.type = "Group"; + group1.ref = "https://example.com/scim/directory/test/Group/group001"; + + AtlassianGuardUserModel.Group group2 = new AtlassianGuardUserModel.Group(); + group2.value = currentGroup2; + group2.type = "Group"; + group2.ref = "https://example.com/scim/directory/test/Group/group002"; + + List groups = new ArrayList<>(); + groups.add(group1); + groups.add(group2); + result.groups = groups; + + h.handle(result); + + return 1; + }); + + // When + List results = new ArrayList<>(); + ResultsHandler handler = connectorObject -> { + results.add(connectorObject); + return true; + }; + connector.search(USER_OBJECT_CLASS, null, handler, defaultSearchOperation()); + + // Then + assertEquals(1, results.size()); + ConnectorObject result = results.get(0); + assertEquals(USER_OBJECT_CLASS, result.getObjectClass()); + assertEquals(userId, result.getUid().getUidValue()); + assertEquals(userName, result.getName().getNameValue()); + assertEquals(formatted, singleAttr(result, "name.formatted")); + assertEquals(toZoneDateTimeForISO8601OffsetDateTime(createdDate), singleAttr(result, "meta.created")); + assertEquals(toZoneDateTimeForISO8601OffsetDateTime(updatedDate), singleAttr(result, "meta.lastModified")); + + Attribute groupsAttribute = result.getAttributeByName("groups"); + assertNotNull(groupsAttribute); + List groups = groupsAttribute.getValue(); + assertEquals(2, groups.size()); + assertEquals(currentGroup1, groups.get(0)); + assertEquals(currentGroup2, groups.get(1)); + + assertEquals(20, targetPageSize.get(), "Not page size in the operation option"); + assertEquals(1, targetOffset.get()); + } + + @Test + void getUsersZero() { + // Given + AtomicReference targetPageSize = new AtomicReference<>(); + AtomicReference targetOffset = new AtomicReference<>(); + mockClient.getUsers = ((h, size, offset) -> { + targetPageSize.set(size); + targetOffset.set(offset); + + return 0; + }); + + // When + List results = new ArrayList<>(); + ResultsHandler handler = connectorObject -> { + results.add(connectorObject); + return true; + }; + connector.search(USER_OBJECT_CLASS, null, handler, defaultSearchOperation()); + + // Then + assertEquals(0, results.size()); + assertEquals(20, targetPageSize.get(), "Not default page size in the configuration"); + assertEquals(1, targetOffset.get()); + } + + @Test + void getUsersTwo() { + // Given + AtomicReference targetPageSize = new AtomicReference<>(); + AtomicReference targetOffset = new AtomicReference<>(); + mockClient.getUsers = ((h, size, offset) -> { + targetPageSize.set(size); + targetOffset.set(offset); + + AtlassianGuardUserModel result = new AtlassianGuardUserModel(); + result.id = "1"; + result.userName = "a"; + result.name = new AtlassianGuardUserModel.Name(); + result.name.formatted = "A"; + result.meta = new AtlassianGuardUserModel.Meta(); + result.meta.created = "2024-11-14T05:56:39.79755Z"; + result.meta.lastModified = "2024-11-14T05:56:40.212208Z"; + h.handle(result); + + result = new AtlassianGuardUserModel(); + result.id = "2"; + result.userName = "b"; + result.name = new AtlassianGuardUserModel.Name(); + result.name.formatted = "B"; + result.meta = new AtlassianGuardUserModel.Meta(); + result.meta.created = "2024-11-14T05:56:39.79755Z"; + result.meta.lastModified = "2024-11-14T05:56:40.212208Z"; + h.handle(result); + + return 2; + }); + + // When + List results = new ArrayList<>(); + ResultsHandler handler = connectorObject -> { + results.add(connectorObject); + return true; + }; + connector.search(USER_OBJECT_CLASS, null, handler, defaultSearchOperation()); + + // Then + assertEquals(2, results.size()); + + ConnectorObject result = results.get(0); + assertEquals(USER_OBJECT_CLASS, result.getObjectClass()); + assertEquals("1", result.getUid().getUidValue()); + assertEquals("a", result.getName().getNameValue()); + + result = results.get(1); + assertEquals(USER_OBJECT_CLASS, result.getObjectClass()); + assertEquals("2", result.getUid().getUidValue()); + assertEquals("b", result.getName().getNameValue()); + + assertEquals(20, targetPageSize.get(), "Not default page size in the configuration"); + assertEquals(1, targetOffset.get()); + } + + @Test + void count() { + // Given + AtomicReference targetPageSize = new AtomicReference<>(); + AtomicReference targetOffset = new AtomicReference<>(); + mockClient.getUsers = ((h, size, offset) -> { + targetPageSize.set(size); + targetOffset.set(offset); + + AtlassianGuardUserModel result = new AtlassianGuardUserModel(); + result.id = "1"; + result.userName = "a"; + result.name = new AtlassianGuardUserModel.Name(); + result.name.formatted = "A"; + result.meta = new AtlassianGuardUserModel.Meta(); + result.meta.created = "2024-11-14T05:56:39.79755Z"; + result.meta.lastModified = "2024-11-14T05:56:40.212208Z"; + h.handle(result); + + return 10; + }); + + // When + List results = new ArrayList<>(); + AtomicReference searchResult = new AtomicReference<>(); + SearchResultsHandler handler = new SearchResultsHandler() { + @Override + public void handleResult(SearchResult result) { + searchResult.set(result); + } + + @Override + public boolean handle(ConnectorObject connectorObject) { + results.add(connectorObject); + return true; + } + }; + connector.search(USER_OBJECT_CLASS, null, handler, countSearchOperation()); + + // Then + assertEquals(1, results.size()); + assertEquals(1, targetPageSize.get()); + assertEquals(1, targetOffset.get()); + assertEquals(9, searchResult.get().getRemainingPagedResults()); + assertTrue(searchResult.get().isAllResultsReturned()); + assertNull(searchResult.get().getPagedResultsCookie()); + } + + @Test + void pagedSearch() { + // Given + AtomicReference targetPageSize = new AtomicReference<>(); + AtomicReference targetOffset = new AtomicReference<>(); + mockClient.getUsers = ((h, size, offset) -> { + targetPageSize.set(size); + targetOffset.set(offset); + + AtlassianGuardUserModel result = new AtlassianGuardUserModel(); + result.id = "1"; + result.userName = "a"; + result.name = new AtlassianGuardUserModel.Name(); + result.name.formatted = "A"; + result.meta = new AtlassianGuardUserModel.Meta(); + result.meta.created = "2024-11-14T05:56:39.79755Z"; + result.meta.lastModified = "2024-11-14T05:56:40.212208Z"; + h.handle(result); + + return 6; + }); + + // When + List results = new ArrayList<>(); + AtomicReference searchResult = new AtomicReference<>(); + SearchResultsHandler handler = new SearchResultsHandler() { + @Override + public void handleResult(SearchResult result) { + searchResult.set(result); + } + + @Override + public boolean handle(ConnectorObject connectorObject) { + results.add(connectorObject); + return true; + } + }; + connector.search(USER_OBJECT_CLASS, null, handler, pagedSearchOperation(6, 1)); + + // Then + assertEquals(1, results.size()); + assertEquals(1, targetPageSize.get()); + assertEquals(6, targetOffset.get()); + assertEquals(0, searchResult.get().getRemainingPagedResults()); + assertTrue(searchResult.get().isAllResultsReturned()); + assertNull(searchResult.get().getPagedResultsCookie()); + } + + @Test + void deleteUser() { + // Given + String userId = "12345"; + String userName = "foo"; + + AtomicReference deleted = new AtomicReference<>(); + mockClient.deleteUser = ((uid) -> { + deleted.set(uid); + }); + + // When + connector.delete(USER_OBJECT_CLASS, new Uid(userId, new Name(userName)), new OperationOptionsBuilder().build()); + + // Then + assertEquals(userId, deleted.get().getUidValue()); + assertEquals(userName, deleted.get().getNameHintValue()); + } + + @Test + void deleteUserButNotFound() { + // Given + String userId = "12345"; + String userName = "foo"; + + AtomicReference deleted = new AtomicReference<>(); + mockClient.deleteUser = ((uid) -> { + throw new UnknownUidException(); + }); + + // When + Throwable expect = null; + try { + connector.delete(USER_OBJECT_CLASS, new Uid(userId, new Name(userName)), new OperationOptionsBuilder().build()); + } catch (Throwable t) { + expect = t; + } + + // Then + assertNotNull(expect); + assertTrue(expect instanceof UnknownUidException); + } +} diff --git a/src/test/java/jp/openstandia/connector/atlassian/testutil/AbstractTest.java b/src/test/java/jp/openstandia/connector/atlassian/testutil/AbstractTest.java new file mode 100644 index 0000000..c5b428c --- /dev/null +++ b/src/test/java/jp/openstandia/connector/atlassian/testutil/AbstractTest.java @@ -0,0 +1,162 @@ +/* + * Copyright Nomura Research Institute, Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package jp.openstandia.connector.atlassian.testutil; + +import jp.openstandia.connector.atlassian.AtlassianGuardConfiguration; +import org.identityconnectors.common.security.GuardedString; +import org.identityconnectors.framework.api.APIConfiguration; +import org.identityconnectors.framework.api.ConnectorFacade; +import org.identityconnectors.framework.api.ConnectorFacadeFactory; +import org.identityconnectors.framework.common.objects.*; +import org.identityconnectors.framework.spi.Configuration; +import org.identityconnectors.test.common.TestHelpers; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; + +import java.util.*; +import java.util.concurrent.atomic.AtomicReference; +import java.util.stream.Collectors; + +public abstract class AbstractTest { + + protected ConnectorFacade connector; + protected AtlassianGuardConfiguration configuration; + protected MockClient mockClient; + + protected AtlassianGuardConfiguration newConfiguration() { + AtlassianGuardConfiguration conf = new AtlassianGuardConfiguration(); + conf.setBaseURL("https://example.com"); + conf.setToken(new GuardedString("dummy".toCharArray())); + return conf; + } + + protected ConnectorFacade newFacade(Configuration configuration) { + ConnectorFacadeFactory factory = ConnectorFacadeFactory.getInstance(); + APIConfiguration impl = TestHelpers.createTestConfiguration(LocalAtlassianGuardConnector.class, configuration); + impl.getResultsHandlerConfiguration().setEnableAttributesToGetSearchResultsHandler(false); + impl.getResultsHandlerConfiguration().setEnableNormalizingResultsHandler(false); + impl.getResultsHandlerConfiguration().setEnableFilteredResultsHandler(false); + return factory.newInstance(impl); + } + + @BeforeEach + void before() { + MockClient.instance().init(); + + this.configuration = newConfiguration(); + this.connector = newFacade(this.configuration); + this.mockClient = MockClient.instance(); + this.mockClient.init("mock", this.configuration, null); + } + + @AfterEach + void after() { + ConnectorFacadeFactory.getInstance().dispose(); + } + + // Utilities + + protected List list(T... s) { + return Arrays.stream(s).collect(Collectors.toList()); + } + + protected Set set(T... s) { + return Arrays.stream(s).collect(Collectors.toSet()); + } + + protected Set asSet(Collection c) { + return new HashSet<>(c); + } + + protected String toPlain(GuardedString gs) { + AtomicReference plain = new AtomicReference<>(); + gs.access(c -> { + plain.set(String.valueOf(c)); + }); + return plain.get(); + } + + protected OperationOptions defaultGetOperation(String... explicit) { + List attrs = Arrays.stream(explicit).collect(Collectors.toList()); + attrs.add(OperationalAttributes.PASSWORD_NAME); + attrs.add(OperationalAttributes.ENABLE_NAME); + + return new OperationOptionsBuilder() + .setReturnDefaultAttributes(true) + .setAttributesToGet(attrs) + .setAllowPartialResults(true) + .build(); + } + + protected OperationOptions defaultSearchOperation(String... explicit) { + List attrs = Arrays.stream(explicit).collect(Collectors.toList()); + attrs.add(OperationalAttributes.PASSWORD_NAME); + attrs.add(OperationalAttributes.ENABLE_NAME); + + return new OperationOptionsBuilder() + .setReturnDefaultAttributes(true) + .setAttributesToGet(attrs) + .setAllowPartialAttributeValues(true) + .setPagedResultsOffset(1) + .setPageSize(20) + .build(); + } + + protected OperationOptions countSearchOperation() { + // IDM try to fetch 1 result to get total number effectively + return pagedSearchOperation(1, 1); + } + + protected OperationOptions pagedSearchOperation(int pageOffset, int pageSize) { + return new OperationOptionsBuilder() + .setAllowPartialAttributeValues(true) + .setPagedResultsOffset(pageOffset) + .setPageSize(pageSize) + .build(); + } + + protected Object singleAttr(ConnectorObject connectorObject, String attrName) { + Attribute attr = connectorObject.getAttributeByName(attrName); + if (attr == null) { + Assertions.fail(attrName + " is not contained in the connectorObject: " + connectorObject); + } + List value = attr.getValue(); + if (value == null || value.size() != 1) { + Assertions.fail(attrName + " is not single value: " + value); + } + return value.get(0); + } + + protected List multiAttr(ConnectorObject connectorObject, String attrName) { + Attribute attr = connectorObject.getAttributeByName(attrName); + if (attr == null) { + Assertions.fail(attrName + " is not contained in the connectorObject: " + connectorObject); + } + List value = attr.getValue(); + if (value == null) { + Assertions.fail(attrName + " is not multiple value: " + value); + } + return value; + } + + protected boolean isIncompleteAttribute(Attribute attr) { + if (attr == null) { + return false; + } + return attr.getAttributeValueCompleteness().equals(AttributeValueCompleteness.INCOMPLETE); + } +} diff --git a/src/test/java/jp/openstandia/connector/atlassian/testutil/LocalAtlassianGuardConnector.java b/src/test/java/jp/openstandia/connector/atlassian/testutil/LocalAtlassianGuardConnector.java new file mode 100644 index 0000000..f373d02 --- /dev/null +++ b/src/test/java/jp/openstandia/connector/atlassian/testutil/LocalAtlassianGuardConnector.java @@ -0,0 +1,25 @@ +/* + * Copyright Nomura Research Institute, Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package jp.openstandia.connector.atlassian.testutil; + +import jp.openstandia.connector.atlassian.AtlassianGuardConnector; + +public class LocalAtlassianGuardConnector extends AtlassianGuardConnector { + @Override + protected void authenticateResource() { + client = MockClient.instance(); + } +} \ No newline at end of file diff --git a/src/test/java/jp/openstandia/connector/atlassian/testutil/MockClient.java b/src/test/java/jp/openstandia/connector/atlassian/testutil/MockClient.java new file mode 100644 index 0000000..b1551a9 --- /dev/null +++ b/src/test/java/jp/openstandia/connector/atlassian/testutil/MockClient.java @@ -0,0 +1,169 @@ +/* + * Copyright Nomura Research Institute, Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package jp.openstandia.connector.atlassian.testutil; + +import jp.openstandia.connector.atlassian.AtlassianGuardGroupModel; +import jp.openstandia.connector.atlassian.AtlassianGuardRESTClient; +import jp.openstandia.connector.atlassian.AtlassianGuardUserModel; +import jp.openstandia.connector.atlassian.PatchOperationsModel; +import jp.openstandia.connector.util.QueryHandler; +import org.identityconnectors.framework.common.exceptions.AlreadyExistsException; +import org.identityconnectors.framework.common.exceptions.UnknownUidException; +import org.identityconnectors.framework.common.objects.Name; +import org.identityconnectors.framework.common.objects.OperationOptions; +import org.identityconnectors.framework.common.objects.Uid; + +import java.util.Set; + +public class MockClient extends AtlassianGuardRESTClient { + + private static MockClient INSTANCE = new MockClient(); + + // User + public MockFunction createUser; + public MockBiConsumer patchUser; + public MockFunction getUserByUid; + public MockFunction getUserByName; + public MockTripleFunction, Integer, Integer, Integer> getUsers; + public MockConsumer deleteUser; + + // Group + public MockFunction createGroup; + public MockBiConsumer patchGroup; + public MockBiConsumer renameGroup; + public MockFunction getGroupByUid; + public MockFunction getGroupByName; + public MockTripleFunction, Integer, Integer, Integer> getGroups; + public MockConsumer deleteGroup; + + public boolean closed = false; + + public void init() { + INSTANCE = new MockClient(); + } + + private MockClient() { + } + + public static MockClient instance() { + return INSTANCE; + } + + @Override + public void test() { + } + + @Override + public void close() { + closed = true; + } + + // User + + @Override + public Uid createUser(AtlassianGuardUserModel newUser) throws AlreadyExistsException { + return createUser.apply(newUser); + } + + @Override + public void patchUser(Uid uid, PatchOperationsModel operations) { + patchUser.accept(uid, operations); + } + + @Override + public AtlassianGuardUserModel getUser(Uid uid, OperationOptions options, Set fetchFieldsSet) throws UnknownUidException { + return getUserByUid.apply(uid); + } + + @Override + public AtlassianGuardUserModel getUser(Name name, OperationOptions options, Set fetchFieldsSet) throws UnknownUidException { + return getUserByName.apply(name); + } + + @Override + public int getUsers(QueryHandler handler, OperationOptions options, Set fetchFieldsSet, int pageSize, int pageOffset) { + return getUsers.apply(handler, pageSize, pageOffset); + } + + @Override + public void deleteUser(Uid uid) { + deleteUser.accept(uid); + } + + // Group + + @Override + public Uid createGroup(AtlassianGuardGroupModel group) throws AlreadyExistsException { + return createGroup.apply(group); + } + + @Override + public void patchGroup(Uid uid, PatchOperationsModel operations) { + patchGroup.accept(uid, operations); + } + + @Override + public AtlassianGuardGroupModel getGroup(Uid uid, OperationOptions options, Set fetchFieldsSet) { + return getGroupByUid.apply(uid); + } + + @Override + public AtlassianGuardGroupModel getGroup(Name name, OperationOptions options, Set fetchFieldsSet) { + return getGroupByName.apply(name); + } + + @Override + public int getGroups(QueryHandler handler, OperationOptions options, Set fetchFieldsSet, int pageSize, int pageOffset) { + return getGroups.apply(handler, pageSize, pageOffset); + } + + @Override + public void deleteGroup(Uid uid) { + deleteGroup.accept(uid); + } + + // Mock Interface + + @FunctionalInterface + public interface MockFunction { + R apply(T t); + } + + @FunctionalInterface + public interface MockBiFunction { + R apply(T t, U u); + } + + @FunctionalInterface + public interface MockTripleFunction { + R apply(T t, U u, V v); + } + + @FunctionalInterface + public interface MockConsumer { + void accept(T t); + } + + @FunctionalInterface + public interface MockBiConsumer { + void accept(T t, U u); + } + + @FunctionalInterface + public interface MockTripleConsumer { + void accept(T t, U u, V v); + } +}