diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c0e39fb --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +.DS_Store +target/ +example/apply.rety +example/roles +.project +.classpath +.vscode +.settings +.factorypath + diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..8dada3e --- /dev/null +++ b/LICENSE.txt @@ -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..7994b1b --- /dev/null +++ b/README.md @@ -0,0 +1,72 @@ +# Openshift Authentication Plugin for SonarQube + +## Description + +This plugin enables user authentication and Single Sign-On via OpenShift. It is heavily based on the code by Julien Lancelot. Tested on version 7 of Sonarqube and OCP 3.11. Intended to run deployed in a pod on OpenShift. + +This plugin is designed to work out of the box without configuration. During plugin deployment, it looks up oauth information from OpenShift's well-known information and takes advantage of information already on the running pod. + +During deployment the plugin will: + +- Look like well-known oauth information at https://openshift.default.svc +- Pull the service account client_id, secret and cert from the file system +- Pull the OpenShift API location from the env variables of the pod +- Get the ServiceAccount name from the API +- Get the Route of the service that is coordinating sonarqube for callback. This relies on the service name being available in the configuration (via sonar.properties). As a fallback it will default to `sonarqube` + +## Installation + +This plugin is not currently hosted anywhere. So build and place this plugin on to the volume where Sonarqube reads plugins at startup. Typically, this might be `/opt/sonarqube/data/plugins`. + +The service account can be used as the oauth client in OpenShift. The service account that runs Sonarqube should have a redirect uri that references the route that Sonarqube is using. You must specify this service account in the DeploymentConfig. + +``` +- apiVersion: v1 + kind: ServiceAccount + metadata: + annotations: + serviceaccounts.openshift.io/oauth-redirectreference.sonarqube: '{"kind":"OAuthRedirectReference","apiVersion":"v1","reference":{"kind":"Route","name":"sonarqube"}}' + name: sonarqube +``` + +The service account must have the ability to view routes in the project. + +``` +oc policy add-role-to-user view system:serviceaccount:sonarqube-project:sonarqube +``` + +The environment variable sonar.auth.openshift.isEnabled must be set to true. The preferred way is to place that value in the sonar.properties file during your container build: + +``` +sonar.auth.openshift.isEnabled=true +``` + +You may also enable it in the Administrative console + +## Configuration + +This plugin will map OpenShift roles to Sonarqube roles. These values are set with the property (shown with the default value if property is not set) + +``` +sonar.auth.openshift.sar.groups=admin=sonar-administrators,edit=sonar-users,view=sonar-users +``` + +The default shown will allow admin users of the project the role of sonar-administrators of Sonarqube. Edit and View role users will be added as sonar-users. + +You may choose the background color of the log in button with the property + +``` +sonar.auth.openshift.button.color=#666666 +``` + +Set the kubernetes API. In the example the API is set automatically with environment variables + +``` +kubernetes.service=https://${env:KUBERNETES_SERVICE_HOST}:${env:KUBERNETES_SERVICE_PORT}/ +``` + +See the example set up using the [OpenShift Applier](https://github.com/redhat-cop/openshift-applier) [here](example/README.md) + +### License + +Licensed under the [Apache License](http://www.apache.org/licenses/LICENSE-2.0.txt) diff --git a/example/Dockerfile b/example/Dockerfile new file mode 100644 index 0000000..5bd8f69 --- /dev/null +++ b/example/Dockerfile @@ -0,0 +1,16 @@ +FROM docker.io/sonarqube:latest + +USER root +ARG sonar_plugins="pmd ldap" +ADD sonar.properties /opt/sonarqube/conf/sonar.properties +ADD run.sh /opt/sonarqube/bin/run.sh +CMD /opt/sonarqube/bin/run.sh +RUN cp -a /opt/sonarqube/data /opt/sonarqube/data-init && \ + cp -a /opt/sonarqube/extensions /opt/sonarqube/extensions-init && \ + chown root:root /opt/sonarqube && chmod -R gu+rwX /opt/sonarqube +ADD plugins.sh /opt/sonarqube/bin/plugins.sh +RUN /opt/sonarqube/bin/plugins.sh $sonar_plugins +ADD sonar-auth-openshift-plugin-1.0.0.jar /opt/sonarqube/extensions-init/plugins/sonar-auth-openshift-plugin-1.0.0.jar +RUN chown root:root /opt/sonarqube -R; \ + chmod 6775 /opt/sonarqube -R +USER 1001 diff --git a/example/README.md b/example/README.md new file mode 100644 index 0000000..2a9fdd6 --- /dev/null +++ b/example/README.md @@ -0,0 +1,44 @@ +# Example Build and Deploy + + +``` +Instructions use ansible. Windows users use a vm or apply each command manually +``` + +This section contains an example for how this plugin can be used with Sonarqube + +## Instructions + +1. Build and copy your built or downloaded plugin into the example folder + +``` +mvn clean package && cp target/sonar-auth-openshift-plugin-1.0.0.jar example/ +``` + +2. Create a project in OpenShift + +``` +oc new-project sonarqube +``` + +3. From the example folder run the prerequisites + +``` +ansible-galaxy install -r requirements.yml --roles-path=roles +``` + +4. From the example folder run the ansible playbook which sets up the build and deploy for Sonarqube including a persistent volume and database + +``` +ansible-playbook -i inventory/ apply.yml +``` + +5. From the base folder build the Docker container on OpenShift + +``` +oc start-build sonarqube --from-dir=. -n sonarqube +``` + +## Explore + +To explore the how this built, explore the files in the subfolders here, particularly `example/inventory/group_vars/all.yml` \ No newline at end of file diff --git a/example/apply.yml b/example/apply.yml new file mode 100755 index 0000000..a2ed576 --- /dev/null +++ b/example/apply.yml @@ -0,0 +1,7 @@ +- name: Deploy OpenShift-Applier Inventory + hosts: seed-hosts[0] + tasks: + - include_role: + name: roles/openshift-applier/roles/openshift-applier + tags: + - openshift-applier \ No newline at end of file diff --git a/example/inventory/group_vars/all.yml b/example/inventory/group_vars/all.yml new file mode 100755 index 0000000..e153c4a --- /dev/null +++ b/example/inventory/group_vars/all.yml @@ -0,0 +1,45 @@ +--- +ansible_connection: local +ci_cd_namespace: sonarqube +sonarqube_name: sonarqube +sonarqube: + build: + NAME: "{{ sonarqube_name }}" + SOURCE_REPOSITORY_URL: "https://github.com/rht-labs/sonar-auth-openshift.git" + SOURCE_REPOSITORY_REF: "master" + SOURCE_CONTEXT_DIR: example + FROM_DOCKER_IMAGE: sonarqube + FROM_DOCKER_TAG: "7.7-community" + FROM_DOCKER_IMAGE_REGISTRY_URL: "docker.io/sonarqube" + postgresql: + POSTGRESQL_DATABASE: sonar + VOLUME_CAPACITY: 5Gi + POSTGRESQL_PASSWORD: sonar + POSTGRESQL_USER: sonar + DATABASE_SERVICE_NAME: sonardb + deploy: + POSTGRES_DATABASE_NAME: "sonar" + +openshift_cluster_content: +- object: build + content: + - name: sonarqube + template: "https://raw.githubusercontent.com/redhat-cop/containers-quickstarts/v1.14/build-docker-generic/.openshift/templates/docker-build-template-override-FROM.yml" + params_from_vars: "{{ sonarqube.build }}" + namespace: "{{ ci_cd_namespace }}" + tags: + - sonarqube +- object: deployment + content: + - name: sonardb + template: "openshift//postgresql-persistent" + params_from_vars: "{{ sonarqube.postgresql }}" + namespace: "{{ ci_cd_namespace }}" + tags: + - sonarqube + - name: sonarqube + template: "{{ playbook_dir }}/templates/sonarqube-deploy.yml" + params_from_vars: "{{ sonarqube.deploy }}" + namespace: "{{ ci_cd_namespace }}" + tags: + - sonarqube diff --git a/example/inventory/host_vars/localhost.yml b/example/inventory/host_vars/localhost.yml new file mode 100755 index 0000000..ada649d --- /dev/null +++ b/example/inventory/host_vars/localhost.yml @@ -0,0 +1 @@ +ansible_connection: local \ No newline at end of file diff --git a/example/inventory/hosts b/example/inventory/hosts new file mode 100755 index 0000000..d73dc81 --- /dev/null +++ b/example/inventory/hosts @@ -0,0 +1,2 @@ +[seed-hosts] +localhost \ No newline at end of file diff --git a/example/plugins.sh b/example/plugins.sh new file mode 100755 index 0000000..b532090 --- /dev/null +++ b/example/plugins.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash + +set -e +# set -x ## Uncomment for debugging + +printf 'Downloading plugin details\n' + +## Extract sonarqube version +export SQ_VERSION=$(ls /opt/sonarqube/lib/sonar-application* | awk -F"-" '{print $3}' | sed 's@\.jar$@@g') +echo "SONARQUBE_VERSION: ${SQ_VERSION}" + + +curl -L -sS -o /tmp/pluginList.txt https://update.sonarsource.org/update-center.properties +printf "Downloading additional plugins\n" +for PLUGIN in "$@" +do + printf '\tExtracting plugin download location - %s\n' ${PLUGIN} + MATCH_STRING=$(cat /tmp/pluginList.txt | grep requiredSonarVersions | grep -E "[,=]${SQ_VERSION}(,|$)" | sed 's@\.requiredSonarVersions.*@@g' | sort -V | grep "^${PLUGIN}\." | tail -n 1 | sed 's@$@.downloadUrl@g') + + if ! [[ -z "${MATCH_STRING}" ]]; then + DOWNLOAD_URL=$(cat /tmp/pluginList.txt | grep ${MATCH_STRING} | awk -F"=" '{print $2}' | sed 's@\\:@:@g') + PLUGIN_FILE=$(echo ${DOWNLOAD_URL} | sed 's@.*/\(.*\)$@\1@g') + + ## Check to see if plugin exists, attempt to download the plugin if it does exist. + if ! [[ -z "${DOWNLOAD_URL}" ]]; then + curl -L -sS -o /opt/sonarqube/extensions-init/plugins/${PLUGIN_FILE} ${DOWNLOAD_URL} && printf "\t\t%-35s%10s" "${PLUGIN_FILE}" "DONE" || printf "\t\t%-35s%10s" "${PLUGIN_FILE}" "FAILED" + printf "\n" + else + ## Plugin was not found in the plugin inventory + printf "\t\t%-15s%10s\n" "${PLUGIN}" "NOT FOUND" + fi + else + printf "\t\t%-15s%10s\n" $PLUGIN "NOT FOUND" + fi +done + +ls /opt/sonarqube/extensions-init/plugins/ + +rm -f /tmp/pluginList.txt diff --git a/example/requirements.yml b/example/requirements.yml new file mode 100755 index 0000000..0fdee54 --- /dev/null +++ b/example/requirements.yml @@ -0,0 +1,4 @@ +- src: https://github.com/redhat-cop/openshift-applier + scm: git + version: v2.1.1 + name: openshift-applier diff --git a/example/run.sh b/example/run.sh new file mode 100755 index 0000000..60992e5 --- /dev/null +++ b/example/run.sh @@ -0,0 +1,28 @@ +#!/bin/bash + +set -x +set -e + +## If the mounted data volume is empty, populate it from the default data +cp -a /opt/sonarqube/data-init/* /opt/sonarqube/data/ + +## Link the plugins directory from the mounted volume +rm -rf /opt/sonarqube/extensions/plugins +ln -s /opt/sonarqube/data/plugins /opt/sonarqube/extensions/plugins + +mkdir -p /opt/sonarqube/data/plugins +for I in $(ls /opt/sonarqube/extensions-init/plugins/*.jar); +do + TARGET_PATH=$(echo ${I} | sed 's@extensions-init/plugins@data/plugins@g') + if ! [[ -e ${TARGET_PATH} ]]; then + cp ${I} ${TARGET_PATH} + fi +done + +if [ "${1:0:1}" != '-' ]; then + exec "$@" +fi + +java -jar lib/sonar-application-$SONAR_VERSION.jar \ + -Dsonar.web.javaAdditionalOpts="${SONARQUBE_WEB_JVM_OPTS} -Djava.security.egd=file:/dev/./urandom" \ + "$@" \ No newline at end of file diff --git a/example/sonar.properties b/example/sonar.properties new file mode 100644 index 0000000..c2a3547 --- /dev/null +++ b/example/sonar.properties @@ -0,0 +1,14 @@ +sonar.log.console=true +sonar.jdbc.username=${env:JDBC_USERNAME} +sonar.jdbc.password=${env:JDBC_PASSWORD} +sonar.jdbc.url=${env:JDBC_URL} +sonar.forceAuthentication=${env:FORCE_AUTHENTICATION} +sonar.authenticator.createUsers=${env:SONAR_AUTOCREATE_USERS} +sonar.log.level=${env:SONAR_LOG_LEVEL} +http.proxyHost=${env:PROXY_HOST} +http.proxyPort=${env:PROXY_PORT} +http.proxyUser=${env:PROXY_USER} +http.proxyPassword=${env:PROXY_PASSWORD} +kubernetes.service=https://${env:KUBERNETES_SERVICE_HOST}:${env:KUBERNETES_SERVICE_PORT}/ +sonar.auth.openshift.isEnabled=true +sonar.auth.openshift.button.color=#000000 diff --git a/example/templates/sonarqube-deploy.yml b/example/templates/sonarqube-deploy.yml new file mode 100644 index 0000000..7547898 --- /dev/null +++ b/example/templates/sonarqube-deploy.yml @@ -0,0 +1,260 @@ +apiVersion: v1 +kind: Template +metadata: + name: "sonarqube" +objects: +- apiVersion: v1 + kind: ServiceAccount + metadata: + annotations: + serviceaccounts.openshift.io/oauth-redirectreference.sonarqube: '{"kind":"OAuthRedirectReference","apiVersion":"v1","reference":{"kind":"Route","name":"sonarqube"}}' + name: sonarqube +- apiVersion: v1 + kind: RoleBinding + metadata: + name: sonarqube_view + roleRef: + name: view + subjects: + - kind: ServiceAccount + name: sonarqube + userNames: + - system:serviceaccount:sonarqube:sonarqube +- apiVersion: v1 + kind: Secret + stringData: + password: ${SONAR_LDAP_BIND_PASSWORD} + username: ${SONAR_LDAP_BIND_DN} + metadata: + name: sonar-ldap-bind-dn + type: kubernetes.io/basic-auth +- apiVersion: v1 + kind: PersistentVolumeClaim + metadata: + name: sonarqube-data + spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: ${SONARQUBE_PERSISTENT_VOLUME_SIZE} + status: {} +- apiVersion: v1 + kind: DeploymentConfig + metadata: + generation: 1 + labels: + app: sonarqube + name: sonarqube + spec: + replicas: 1 + selector: + app: sonarqube + deploymentconfig: sonarqube + strategy: + activeDeadlineSeconds: 21600 + recreateParams: + timeoutSeconds: 600 + post: + execNewPod: + command: + - /bin/sh + - -c + - sleep 30 && curl http://admin:admin@sonarqube:9000/api/webhooks/create + -X POST -d "name=jenkins&url=${JENKINS_URL}/sonarqube-webhook/" + containerName: sonarqube + failurePolicy: Abort + type: Recreate + template: + metadata: + annotations: + openshift.io/generated-by: OpenShiftWebConsole + labels: + app: sonarqube + deploymentconfig: sonarqube + spec: + containers: + - env: + - name: JDBC_URL + value: jdbc:postgresql://sonardb:5432/sonar + - name: JDBC_USERNAME + valueFrom: + secretKeyRef: + key: database-user + name: sonardb + - name: JDBC_PASSWORD + valueFrom: + secretKeyRef: + key: database-password + name: sonardb + - name: FORCE_AUTHENTICATION + value: "true" + - name: PROXY_HOST + value: ${PROXY_HOST} + - name: PROXY_PORT + value: ${PROXY_PORT} + - name: PROXY_USER + value: ${PROXY_USER} + - name: PROXY_PASSWORD + value: ${PROXY_PASSWORD} + imagePullPolicy: Always + livenessProbe: + failureThreshold: 3 + httpGet: + path: / + port: 9000 + scheme: HTTP + initialDelaySeconds: 45 + periodSeconds: 10 + successThreshold: 1 + timeoutSeconds: 1 + name: sonarqube + ports: + - containerPort: 9000 + protocol: TCP + readinessProbe: + failureThreshold: 3 + httpGet: + path: / + port: 9000 + scheme: HTTP + initialDelaySeconds: 10 + periodSeconds: 10 + successThreshold: 1 + timeoutSeconds: 1 + resources: {} + terminationMessagePath: /dev/termination-log + terminationMessagePolicy: File + volumeMounts: + - mountPath: /opt/sonarqube/data + name: sonar-data + dnsPolicy: ClusterFirst + restartPolicy: Always + schedulerName: default-scheduler + securityContext: {} + serviceAccount: sonarqube + serviceAccountName: sonarqube + terminationGracePeriodSeconds: 30 + volumes: + - name: sonar-data + persistentVolumeClaim: + claimName: sonarqube-data + test: false + triggers: + - imageChangeParams: + automatic: true + containerNames: + - sonarqube + from: + kind: ImageStreamTag + name: sonarqube:latest + type: ImageChange + - type: ConfigChange +- apiVersion: v1 + kind: Route + metadata: + labels: + app: sonarqube + name: sonarqube + spec: + port: + targetPort: 9000-tcp + tls: + termination: edge + to: + kind: Service + name: sonarqube + weight: 100 + wildcardPolicy: None +- apiVersion: v1 + kind: Service + metadata: + labels: + app: sonarqube + name: sonarqube + spec: + ports: + - name: 9000-tcp + port: 9000 + protocol: TCP + targetPort: 9000 + selector: + deploymentconfig: sonarqube + sessionAffinity: None + type: ClusterIP +parameters: + - description: Database name for the Posgres Database to be used by Sonarqube + displayName: Postgres database name + name: POSTGRES_DATABASE_NAME + value: sonar + required: true + - name: SONARQUBE_PERSISTENT_VOLUME_SIZE + description: The persistent storage volume for SonarQube to use for plugins/config/logs/etc... + displayName: SonarQube Storage Space Size + required: true + value: 5Gi + - name: SONAR_AUTH_REALM + value: '' + description: The type of authentication that SonarQube should be using (None or LDAP) (Ref - https://docs.sonarqube.org/display/PLUG/LDAP+Plugin) + displayName: SonarQube Authentication Realm + - name: SONAR_AUTOCREATE_USERS + value: 'false' + description: When using an external authentication system, should SonarQube automatically create accounts for users? + displayName: Enable auto-creation of users from external authentication systems? + required: true + - name: PROXY_HOST + description: Hostname of proxy server the SonarQube application should use to access the Internet + displayName: Proxy server hostname/IP + - name: PROXY_PORT + description: TCP port of proxy server the SonarQube application should use to access the Internet + displayName: Proxy server port + - name: PROXY_USER + description: Username credential when the Proxy Server requires authentication + displayName: Proxy server username + - name: PROXY_PASSWORD + description: Password credential when the Proxy Server requires authentication + displayName: Proxy server password + - name: SONAR_LDAP_BIND_DN + description: When using LDAP authentication, this is the Distinguished Name used for binding to the LDAP server + displayName: LDAP Bind DN + - name: SONAR_LDAP_BIND_PASSWORD + description: When using LDAP for authentication, this is the password with which to bind to the LDAP server + displayName: LDAP Bind Password + - name: SONAR_LDAP_URL + description: When using LDAP for authentication, this is the URL of the LDAP server in the form of ldap(s)://: + displayName: LDAP Server URL + - name: SONAR_LDAP_REALM + description: When using LDAP, this allows for specifying a Realm within the directory server (Usually not used) + displayName: LDAP Realm + - name: SONAR_LDAP_AUTHENTICATION + description: When using LDAP, this is the bind method (simple, GSSAPI, kerberos, CRAM-MD5, DIGEST-MD5) + displayName: LDAP Bind Mode + - name: SONAR_LDAP_USER_BASEDN + description: The Base DN under which SonarQube should search for user accounts in the LDAP directory + displayName: LDAP User Base DN + - name: SONAR_LDAP_USER_REAL_NAME_ATTR + description: The LDAP attribute which should be referenced to get a user's full name + displayName: LDAP Real Name Attribute + - name: SONAR_LDAP_USER_EMAIL_ATTR + description: The LDAP attribute which should be referenced to get a user's e-mail address + displayName: LDAP User E-Mail Attribute + - name: SONAR_LDAP_USER_REQUEST + description: An LDAP filter to be used to search for user objects in the LDAP directory + displayName: LDAP User Request Filter + - name: SONAR_LDAP_GROUP_BASEDN + description: The Base DN under which SonarQube should search for groups in the LDAP directory + displayName: LDAP Group Base DN + - name: SONAR_LDAP_GROUP_REQUEST + description: An LDAP filter to be used to search for group objects in the LDAP directory + displayName: LDAP Group Request Filter + - name: SONAR_LDAP_GROUP_ID_ATTR + description: The LDAP attribute which should be referenced to get a group's ID + displayName: LDAP Group Name Attribute + - name: SONAR_LDAP_CONTEXTFACTORY + description: The ContextFactory implementation to be used when communicating with the LDAP server + displayName: LDAP Context Factory + value: com.sun.jndi.ldap.LdapCtxFactory + - name: JENKINS_URL + description: The Jenkins URL used for the webhook + displayName: Jenkins URL + value: http://jenkins diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..c797310 --- /dev/null +++ b/pom.xml @@ -0,0 +1,168 @@ + + + 4.0.0 + + org.sonarsource.auth.openshift + sonar-auth-openshift-plugin + sonar-plugin + 1.0.0 + + OpenShift Authentication for SonarQube + + + + UTF-8 + 7.3 + 1.8 + + + + + io.kubernetes + client-java + 5.0.0 + compile + + + org.slf4j + jcl-over-slf4j + 1.7.25 + + + org.apache.httpcomponents + httpclient + 4.5.5 + + + commons-logging + commons-logging + + + + + + com.google.oauth-client + google-oauth-client + 1.20.0 + + + + com.google.http-client + google-http-client-jackson2 + 1.20.0 + + + + com.google.oauth-client + google-oauth-client-java6 + 1.20.0 + + + + org.sonarsource.sonarqube + sonar-plugin-api + ${sonar.apiVersion} + provided + + + + javax.servlet + javax.servlet-api + 3.0.1 + provided + + + + com.google.code.gson + gson + 2.8.2 + + + + commons-lang + commons-lang + 2.6 + + + + com.github.scribejava + scribejava-core + 4.2.0 + + + + com.github.scribejava + scribejava-httpclient-okhttp + 4.2.0 + + + + + + org.sonarsource.sonarqube + sonar-testing-harness + ${sonar.apiVersion} + test + + + + junit + junit + 4.11 + test + + + + org.mockito + mockito-core + 3.0.0 + test + + + + + + + + + org.sonarsource.sonar-packaging-maven-plugin + sonar-packaging-maven-plugin + 1.16 + true + + com.rhc.sonarqube.auth.openshift.AuthOpenShiftPlugin + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.5.1 + + ${jdk.min.version} + ${jdk.min.version} + + + + + + org.codehaus.mojo + native2ascii-maven-plugin + 1.0-beta-1 + + + + native2ascii + + + + + + + diff --git a/src/main/java/com/rhc/sonarqube/auth/openshift/AuthOpenShiftPlugin.java b/src/main/java/com/rhc/sonarqube/auth/openshift/AuthOpenShiftPlugin.java new file mode 100644 index 0000000..e1f26e5 --- /dev/null +++ b/src/main/java/com/rhc/sonarqube/auth/openshift/AuthOpenShiftPlugin.java @@ -0,0 +1,16 @@ + +package com.rhc.sonarqube.auth.openshift; + +import org.sonar.api.Plugin; +import java.util.logging.Logger; + +public class AuthOpenShiftPlugin implements Plugin { + + static final Logger LOGGER = Logger.getLogger(AuthOpenShiftPlugin.class.getName()); + + @Override + public void define(Context context) { + context.addExtensions(OpenShiftScribeApi.class, OpenShiftConfiguration.class, OpenShiftIdentityProvider.class); + context.addExtensions(OpenShiftConfiguration.definitions()); + } +} diff --git a/src/main/java/com/rhc/sonarqube/auth/openshift/OpenShiftConfiguration.java b/src/main/java/com/rhc/sonarqube/auth/openshift/OpenShiftConfiguration.java new file mode 100644 index 0000000..0b6fc76 --- /dev/null +++ b/src/main/java/com/rhc/sonarqube/auth/openshift/OpenShiftConfiguration.java @@ -0,0 +1,172 @@ +package com.rhc.sonarqube.auth.openshift; + +import java.io.FileNotFoundException; +import java.io.FileReader; +import java.io.IOException; + +import static java.lang.String.valueOf; +import static org.sonar.api.PropertyType.BOOLEAN; +import static org.sonar.api.PropertyType.STRING; + +import java.io.BufferedReader; +import java.io.File; + +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.logging.Logger; + +import com.google.common.base.Splitter; + +import org.sonar.api.config.Configuration; +import org.sonar.api.config.PropertyDefinition; +import org.sonar.api.server.ServerSide; + +@ServerSide +public class OpenShiftConfiguration { + static final Logger LOGGER = Logger.getLogger(OpenShiftConfiguration.class.getName()); + + private final Configuration config; + private static final String DEFAULT_SCOPE = "user:info user:check-access"; + private static final String DEFAULT_GROUPS = "admin=sonar-administrators,edit=sonar-users,view=sonar-users"; + private static final String DEFAULT_SERVICEACCOUNT_DIRECTORY = "/run/secrets/kubernetes.io/serviceaccount"; + + private static final String SERVICEACCOUNT_DIRECTORY_KEY = "kubernetes.service.account.dir"; + + private static final String USER_URI = "oapi/v1/users/~"; + private static final String SAR_URI = "oapi/v1/subjectaccessreviews"; + private static final String ROUTE_URI = "%sapis/route.openshift.io/v1/namespaces/%s/routes/%s"; + private static final String CATEGORY = "OpenShift-Auth"; + private static final String SUBCATEGORY = "Authentication"; + + private static final String WEB_URL = "sonar.auth.openshift.webUrl"; + private static final String API_URL = "kubernetes.service"; + private static final String IS_ENABLED = "sonar.auth.openshift.isEnabled"; + private static final String BUTTON_COLOR = "sonar.auth.openshift.button.color"; + private static final String NAMESPACE = "namespace"; + private static final String TOKEN = "token"; + private static final String CA_CRT = "ca.crt"; + + private static final String CLIENT_ID = "system:serviceaccount:%s:%s"; + + + private String serviceAccountName; + + public OpenShiftConfiguration(Configuration config) { + this.config = config; + } + + public String getCert() { + return CA_CRT; + } + + public String getSarURI() { + return getApiURL() + SAR_URI; + } + + public String getUserURI() { + return getApiURL() + USER_URI; + } + + public String getDefaultScope() { + return DEFAULT_SCOPE; + } + + public String getServiceAccountName() { + return this.serviceAccountName; + } + + public void setServicAccountName(String serviceAccountName) { + this.serviceAccountName = serviceAccountName; + } + + public String getNamespace() throws IOException { + return serviceAccountBufferReader(NAMESPACE); + } + + public boolean isEnabled() { + return config.getBoolean(IS_ENABLED).orElse(true); + } + + public String getOpenShiftServiceAccountDirectory() { + return config.get(SERVICEACCOUNT_DIRECTORY_KEY).orElse(DEFAULT_SERVICEACCOUNT_DIRECTORY); + } + + public boolean getAllowUsersToSignUp() { + return false; + } + + public Map getSARGroups() { + String mapAsString = config.get("sonar.auth.openshift.sar.groups").orElse(DEFAULT_GROUPS); + return Splitter.on(",").withKeyValueSeparator("=").split(mapAsString); + } + + public String getApiURL() { + LOGGER.fine("API url: " + config.get(API_URL).orElse("API url not set")); + return config.get(API_URL).orElse(null); + } + + public String getClientId() throws IOException { + return String.format(CLIENT_ID, getNamespace(), getServiceAccountName()); + } + + public String getClientSecret() throws IOException { + return serviceAccountBufferReader(TOKEN); + } + + public String getButtonColor() { + return config.get(BUTTON_COLOR).orElse("#666666"); + } + + public String getRouteURL(String namespace) { + return String.format(ROUTE_URI, getApiURL(), namespace, "sonarqube"); + } + + public static List definitions() { + int index = 1; + return Arrays.asList( + PropertyDefinition.builder(IS_ENABLED) + .name("Login enabled") + .description("Enable OpenShift users to login. Value is ignored and treated as default if " + + "client ID and client secret cannot be defined.") + .category(CATEGORY) + .subCategory(SUBCATEGORY) + .type(BOOLEAN) + .defaultValue(valueOf(false)) + .index(index++) + .build(), + PropertyDefinition.builder(BUTTON_COLOR) + .name("Login button color") + .description("Set the hex color of the login button. Default is grey") + .category(CATEGORY) + .subCategory(SUBCATEGORY) + .defaultValue("#666666") + .type(STRING) + .index(index++) + .build(), + PropertyDefinition.builder(API_URL) + .name("The API url for an OpenShift instance.") + .description("The API url for an OpenShift instance. By default this plugin will look it up.") + .category(CATEGORY) + .subCategory(SUBCATEGORY) + .type(STRING) + .index(index++) + .build(), + PropertyDefinition.builder(WEB_URL) + .name("The WEB url for a OpenShift instance.") + .description("The Web url for an OpenShift instance. By default this plugin will determine the value") + .category(CATEGORY) + .subCategory(SUBCATEGORY) + .type(STRING) + .index(index) + .build()); + } + + private String serviceAccountBufferReader(String directory) throws FileNotFoundException, IOException{ + BufferedReader bufferReader = new BufferedReader( + new FileReader(new File(getOpenShiftServiceAccountDirectory(), directory))); + String id = bufferReader.readLine(); + bufferReader.close(); + return id; + } +} diff --git a/src/main/java/com/rhc/sonarqube/auth/openshift/OpenShiftIdentityProvider.java b/src/main/java/com/rhc/sonarqube/auth/openshift/OpenShiftIdentityProvider.java new file mode 100644 index 0000000..64e8456 --- /dev/null +++ b/src/main/java/com/rhc/sonarqube/auth/openshift/OpenShiftIdentityProvider.java @@ -0,0 +1,277 @@ +package com.rhc.sonarqube.auth.openshift; + +import java.util.HashSet; +import java.util.Set; +import java.util.logging.Logger; +import java.util.concurrent.ExecutionException; +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.security.GeneralSecurityException; +import java.security.KeyStore; +import java.security.SecureRandom; + +import javax.net.ssl.SSLContext; +import javax.net.ssl.TrustManagerFactory; +import javax.servlet.http.HttpServletRequest; + +import com.github.scribejava.core.model.Verb; +import com.github.scribejava.core.model.Response; +import com.github.scribejava.core.model.OAuthRequest; +import com.github.scribejava.core.oauth.OAuth20Service; +import com.github.scribejava.core.model.OAuth2AccessToken; +import com.github.scribejava.core.builder.ServiceBuilder; + +import org.sonar.api.server.ServerSide; +import org.sonar.api.server.authentication.Display; +import org.sonar.api.server.authentication.UserIdentity; + +import io.kubernetes.client.util.Config; + +import org.sonar.api.server.authentication.OAuth2IdentityProvider; + +import com.google.api.client.util.SecurityUtils; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; + +@ServerSide +public class OpenShiftIdentityProvider implements OAuth2IdentityProvider { + + public static final String KEY = "openshift"; + private String todoCallback = ""; + + private final OpenShiftScribeApi scribeApi; + private final OpenShiftConfiguration config; + + static final Logger LOGGER = Logger.getLogger(OpenShiftIdentityProvider.class.getName()); + + public OpenShiftIdentityProvider(OpenShiftConfiguration config, OpenShiftScribeApi scribeApi) { + this.config = config; + this.scribeApi = scribeApi; + + try { + setKeystore(); + } catch (Exception ex) { + LOGGER.severe("Problem setting up ssl"); + throw new IllegalStateException("Problem setting up ssl", ex); + } + + try { + getServiceAccountName(); + } catch (Exception ex) { + LOGGER.severe("Problem getting service account"); + throw new IllegalStateException("Problem getting service account", ex); + } + + try { + todoCallback = this.callbackBaseUrl(); + } catch (Exception ex) { + LOGGER.severe("Problem getting callback url (base on pod's service"); + throw new IllegalStateException("Problem getting callback url", ex); + } + + LOGGER.fine("OpenShift API " + scribeApi.toString()); + } + + @Override + public String getKey() { + return KEY; + } + + @Override + public String getName() { + return "OpenShift"; + } + + @Override + public Display getDisplay() { + return Display.builder().setIconPath("/static/authopenshift/openshift.svg") + .setBackgroundColor(config.getButtonColor()).build(); + } + + @Override + public boolean isEnabled() { + return config.isEnabled(); + } + + @Override + public boolean allowsUsersToSignUp() { + return true; + } + + @Override + public void init(InitContext context) { + try { + String state = context.generateCsrfState(); + + OAuth20Service scribe = newScribeBuilder().scope(config.getDefaultScope()).state(state).build(scribeApi); + String url = scribe.getAuthorizationUrl(); + LOGGER.fine("Redirect:" + url); + context.redirectTo(url); + } catch (IOException e) { + LOGGER.severe(String.format("Unable to read/write client id and/or client secret from service account.%n")); + throw new IllegalStateException("Unable to complete init", e); + } + } + + @Override + public void callback(CallbackContext context) { + try { + LOGGER.fine("callback!"); + onCallback(context); + } catch (IOException | InterruptedException | ExecutionException e) { + throw new IllegalStateException(e); + } + } + + private void onCallback(CallbackContext context) throws InterruptedException, ExecutionException, IOException { + context.verifyCsrfState(); + HttpServletRequest request = context.getRequest(); + OAuth20Service scribe = newScribeBuilder().build(scribeApi); + String code = request.getParameter("code"); + OAuth2AccessToken accessToken = scribe.getAccessToken(code); + + Set sonarRoles = findSonarRoles(scribe, accessToken); + + LOGGER.fine(String.format("Roles %s", sonarRoles)); + + String user = getOpenShiftUser(scribe, accessToken); + UserIdentity userIdentity = UserIdentity.builder().setGroups(sonarRoles).setProviderLogin(user) + .setLogin(String.format("%s@%s", user, KEY)).setName(user).build(); + + context.authenticate(userIdentity); + context.redirectToRequestedPage(); + } + + private Set findSonarRoles(OAuth20Service scribe, OAuth2AccessToken accessToken) + throws IOException, InterruptedException, ExecutionException { + Response response = null; + + HashSet sonarRoles = new HashSet(); + String namespace = config.getNamespace(); + + OAuthRequest request = new OAuthRequest(Verb.POST, config.getSarURI()); + request.addHeader("Content-Type", "application/json"); + scribe.signRequest(accessToken, request); + + for (String accessRole : config.getSARGroups().keySet()) { + String json = OpenShiftSubjectAccessReviewRequest.createJsonRequest(accessRole, namespace); + LOGGER.fine("SAR body " + json); + request.setPayload(json); + + response = scribe.execute(request); + + if (response.isSuccessful()) { + if (new JsonParser().parse(response.getBody()).getAsJsonObject().get("allowed").getAsBoolean()) { + sonarRoles.add(config.getSARGroups().get(accessRole)); + } + } else { + LOGGER.warning(String.format("SAR Request failed with response message %s", response.getBody())); + } + } + return sonarRoles; + } + + /** + * Get the sonarqube base url by accessing the OpenShift route currently + * hard-coded to sonarqube Jenkins does this by making the service name + * available to the pod in the env vars + * + * @return + * @throws IOException + */ + private String callbackBaseUrl() throws IOException { + OAuth20Service scribe = newScribeBuilder().build(scribeApi); + OAuth2AccessToken token = new OAuth2AccessToken(config.getClientSecret()); + + OAuthRequest request = new OAuthRequest(Verb.GET, config.getRouteURL(config.getNamespace())); + LOGGER.info("URL--->" + request.getUrl()); + scribe.signRequest(token, request); + Response response; + try { + response = scribe.execute(request); + + if (!response.isSuccessful()) { + throw new IllegalStateException( + String.format("Failed to get callback '%s'. HTTP code: %s, response: %s.", + config.getRouteURL(config.getNamespace()), response.getCode(), response.getBody())); + } + + String json = response.getBody(); + JsonObject jsonObject = new JsonParser().parse(json).getAsJsonObject().get("spec").getAsJsonObject(); + String host = jsonObject.get("host").getAsString(); + host = jsonObject.getAsJsonObject().has("tls") ? "https://" + host : "http://" + host; + + LOGGER.info("Callback Host: " + host); + + return host + "/oauth2/callback/openshift"; + } catch (InterruptedException | ExecutionException ex) { + throw new IllegalStateException("Faild to get callback url", ex); + } + } + + /** + * This should be consistent through the lifecycle of the running pod. Called + * during object construcion only + * + * @throws IOException + */ + private void getServiceAccountName() throws IOException { + OAuth20Service scribe = newScribeBuilder().build(scribeApi); + OAuth2AccessToken token = new OAuth2AccessToken(config.getClientSecret()); + try { + String userName = getOpenShiftUser(scribe, token); + if (userName.indexOf(":") >= 0) { + config.setServicAccountName(userName.substring(userName.lastIndexOf(":") + 1)); + LOGGER.info(String.format("Service account name '%s'", config.getServiceAccountName())); + } + } catch (ExecutionException | InterruptedException e) { + throw new IllegalStateException("Unable to figure out service account name", e); + } + + } + + private String getOpenShiftUser(OAuth20Service scribe, OAuth2AccessToken accessToken) + throws IOException, ExecutionException, InterruptedException { + OAuthRequest request = new OAuthRequest(Verb.GET, config.getUserURI()); + scribe.signRequest(accessToken, request); + Response response = scribe.execute(request); + + if (!response.isSuccessful()) { + throw new IllegalStateException(String.format("Failed to get user '%s'. status: %s, response: %s.", + config.getUserURI(), response.getCode(), response.getBody())); + } + String json = response.getBody(); + LOGGER.fine("User response body ===== " + json); + String userName = new JsonParser().parse(json).getAsJsonObject().get("metadata").getAsJsonObject().get("name") + .getAsString(); + return userName; + } + + private ServiceBuilder newScribeBuilder() throws IOException { + if (!isEnabled()) { + throw new IllegalStateException("OpenShift authentication is disabled."); + } + + return new ServiceBuilder(config.getClientId()).apiSecret(config.getClientSecret()).callback(todoCallback); + } + + private void setKeystore() throws IOException, GeneralSecurityException { + KeyStore keyStore = SecurityUtils.getDefaultKeyStore(); + try { + LOGGER.fine("Keystore size " + keyStore.size()); + } catch (Exception ex) { + keyStore.load(null); + } + + FileInputStream fis = new FileInputStream(new File(config.getOpenShiftServiceAccountDirectory(), "ca.crt")); + SecurityUtils.loadKeyStoreFromCertificates(keyStore, SecurityUtils.getX509CertificateFactory(), fis); + LOGGER.fine("Keystore loaded"); + TrustManagerFactory tmf = TrustManagerFactory.getInstance("SunX509", "SunJSSE"); + tmf.init(keyStore); + SSLContext ssl = SSLContext.getInstance("TLS"); + ssl.init(Config.defaultClient().getKeyManagers(), tmf.getTrustManagers(), new SecureRandom()); + SSLContext.setDefault(ssl); + LOGGER.fine("SSL context set"); + } +} diff --git a/src/main/java/com/rhc/sonarqube/auth/openshift/OpenShiftProviderInfo.java b/src/main/java/com/rhc/sonarqube/auth/openshift/OpenShiftProviderInfo.java new file mode 100644 index 0000000..784b700 --- /dev/null +++ b/src/main/java/com/rhc/sonarqube/auth/openshift/OpenShiftProviderInfo.java @@ -0,0 +1,35 @@ +package com.rhc.sonarqube.auth.openshift; + +import com.github.scribejava.core.builder.api.DefaultApi20; +import com.google.api.client.util.Key; + +/** + * OpenShiftProviderInfo + */ +public class OpenShiftProviderInfo extends DefaultApi20 { + + @Key + public String issuer; //console url 3.11 + + @Key + public String authorization_endpoint; //issuer + auth + + @Key + public String token_endpoint; //issuer + token + + @Override + public String toString() { + return "OpenShiftProviderInfo: issuer: " + issuer + " auth ep: " + + authorization_endpoint + " token ep: " + token_endpoint; + } + + @Override + public String getAccessTokenEndpoint() { + return token_endpoint; + } + + @Override + protected String getAuthorizationBaseUrl() { + return authorization_endpoint; + } +} \ No newline at end of file diff --git a/src/main/java/com/rhc/sonarqube/auth/openshift/OpenShiftScribeApi.java b/src/main/java/com/rhc/sonarqube/auth/openshift/OpenShiftScribeApi.java new file mode 100644 index 0000000..4aaf422 --- /dev/null +++ b/src/main/java/com/rhc/sonarqube/auth/openshift/OpenShiftScribeApi.java @@ -0,0 +1,74 @@ +package com.rhc.sonarqube.auth.openshift; + +import org.sonar.api.server.ServerSide; + +import io.kubernetes.client.util.Config; + +import java.io.IOException; +import java.util.logging.Logger; + +import com.github.scribejava.core.builder.api.DefaultApi20; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.squareup.okhttp.OkHttpClient; +import com.squareup.okhttp.Request; + + +@ServerSide +public class OpenShiftScribeApi extends DefaultApi20 { + + static final Logger LOGGER = Logger.getLogger(OpenShiftIdentityProvider.class.getName()); + + private String wellKnownDefaultUrl = "https://openshift.default.svc"; + private String wellKnownURI = "/.well-known/oauth-authorization-server"; + + public String issuer; //console url 3.11 + public String authorization_endpoint; //issuer + auth + public String token_endpoint; //issuer + token + + public OpenShiftScribeApi() { + try { + getWellKnown(); + } catch (IOException io) { + LOGGER.severe("Unable to get well known oauth2 information"); + throw new IllegalStateException(io); + } + } + + private void getWellKnown() throws IOException { + LOGGER.fine("Getting well known"); + Config.defaultClient(); + OkHttpClient ok = Config.defaultClient().getHttpClient(); + + Request okrequest = new Request.Builder().url(wellKnownDefaultUrl + wellKnownURI) + .addHeader("Accept", "application/json").build(); + + com.squareup.okhttp.Response okresponse = ok.newCall(okrequest).execute(); + String json = okresponse.body().string(); + + JsonObject jsonObject = new JsonParser().parse(json).getAsJsonObject(); + issuer = jsonObject.get("issuer").getAsString(); + authorization_endpoint = jsonObject.get("authorization_endpoint").getAsString(); + token_endpoint = jsonObject.get("token_endpoint").getAsString(); + + } + + @Override + public String toString() { + return "OpenShiftProviderInfo: issuer: " + issuer + " auth ep: " + + authorization_endpoint + " token ep: " + token_endpoint; + } + + @Override + public String getAccessTokenEndpoint() { + LOGGER.fine("getting access token endpint " + token_endpoint); + return token_endpoint; + } + + @Override + protected String getAuthorizationBaseUrl() { + LOGGER.fine("getting auth endpint " + authorization_endpoint); + return authorization_endpoint; + } + +} diff --git a/src/main/java/com/rhc/sonarqube/auth/openshift/OpenShiftSubjectAccessReviewRequest.java b/src/main/java/com/rhc/sonarqube/auth/openshift/OpenShiftSubjectAccessReviewRequest.java new file mode 100644 index 0000000..a32c06c --- /dev/null +++ b/src/main/java/com/rhc/sonarqube/auth/openshift/OpenShiftSubjectAccessReviewRequest.java @@ -0,0 +1,87 @@ +package com.rhc.sonarqube.auth.openshift; + +import java.util.ArrayList; +import java.util.List; +import com.google.api.client.util.Key; +import com.google.gson.Gson; + +public class OpenShiftSubjectAccessReviewRequest { + + public static final String SUBJECT_ACCESS_REVIEW = "SubjectAccessReview"; + public static final String API_VERSION = "v1"; + public static final String RESOURCE = "sonarqube"; + + public OpenShiftSubjectAccessReviewRequest() { + + kind = SUBJECT_ACCESS_REVIEW; + apiVersion = API_VERSION; + namespace = null; + verb = null; + resourceAPIGroup = ""; + resourceAPIVersion = ""; + resource = RESOURCE; + resourceName = ""; + content = null; + user = ""; + groups = new ArrayList(); + scopes = new ArrayList(); + } + + public static String createJsonRequest(String verb, String namespace) { + OpenShiftSubjectAccessReviewRequest req = new OpenShiftSubjectAccessReviewRequest(); + req.verb = verb; + req.namespace = namespace; + + return new Gson().toJson(req); + } + + @Key + public String kind; + + @Key + public String apiVersion; + + @Key + public String namespace; + + @Key + public String verb; + + @Key + public String resourceAPIGroup; + + @Key + public String resourceAPIVersion; + + @Key + public String resource; + + @Key + public String resourceName; + + @Key + public String content; + + @Key + public String user; + + @Key + public List groups; + + @Key + public List scopes; +} + + + + + + + + + + + + + + diff --git a/src/main/resources/static/openshift.svg b/src/main/resources/static/openshift.svg new file mode 100644 index 0000000..9ebe268 --- /dev/null +++ b/src/main/resources/static/openshift.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/test/java/com/rhc/sonarqube/auth/openshift/AuthOpenShiftPluginTest.java b/src/test/java/com/rhc/sonarqube/auth/openshift/AuthOpenShiftPluginTest.java new file mode 100644 index 0000000..825be1f --- /dev/null +++ b/src/test/java/com/rhc/sonarqube/auth/openshift/AuthOpenShiftPluginTest.java @@ -0,0 +1,28 @@ +package com.rhc.sonarqube.auth.openshift; + +import static org.junit.Assert.assertEquals; + +import org.junit.Test; +import org.sonar.api.Plugin; +import org.sonar.api.SonarQubeSide; +import org.sonar.api.internal.PluginContextImpl; +import org.sonar.api.internal.SonarRuntimeImpl; +import org.sonar.api.utils.Version; + +/** + * AuthOpenShiftPluginTest + */ +public class AuthOpenShiftPluginTest { + + private Plugin.Context context = new PluginContextImpl.Builder() + .setSonarRuntime(SonarRuntimeImpl.forSonarQube(Version.create(7, 3), SonarQubeSide.SERVER)) + .build(); + + private AuthOpenShiftPlugin testPlugin = new AuthOpenShiftPlugin(); + + @Test + public void testExtensions() { + testPlugin.define(context); + assertEquals(7, context.getExtensions().size()); + } +} \ No newline at end of file diff --git a/src/test/java/com/rhc/sonarqube/auth/openshift/OpenShiftIdentityProviderTest.java b/src/test/java/com/rhc/sonarqube/auth/openshift/OpenShiftIdentityProviderTest.java new file mode 100644 index 0000000..5c25497 --- /dev/null +++ b/src/test/java/com/rhc/sonarqube/auth/openshift/OpenShiftIdentityProviderTest.java @@ -0,0 +1,123 @@ +package com.rhc.sonarqube.auth.openshift; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.ExpectedException; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.mockito.junit.MockitoJUnitRunner; +import org.sonar.api.config.PropertyDefinitions; +import org.sonar.api.config.internal.ConfigurationBridge; +import org.sonar.api.config.internal.MapSettings; +import org.sonar.api.server.authentication.OAuth2IdentityProvider; + +import com.github.scribejava.core.builder.api.OAuth2SignatureType; +import com.github.scribejava.core.httpclient.HttpClient; +import com.github.scribejava.core.model.OAuthConfig; +import com.github.scribejava.core.oauth.OAuth20Service; +import com.rhc.sonarqube.auth.openshift.test.MockHttpClient; + +/** + * OpenShiftIdentityProviderTest + */ +@RunWith(MockitoJUnitRunner.class) + +public class OpenShiftIdentityProviderTest { + + @Rule + public ExpectedException thrown = ExpectedException.none(); + + @Mock + private OpenShiftScribeApi scribeApi; + + @Mock + private OAuth20Service oauth2Service; + + @Mock + private HttpClient httpClient; + + private OAuth20Service oauth2Service2; + + @Mock + private + OAuth2IdentityProvider.InitContext context; + + private OpenShiftIdentityProvider testProvider; + + private MapSettings settings = new MapSettings(new PropertyDefinitions((OpenShiftConfiguration.definitions()))); + private OpenShiftConfiguration config = new OpenShiftConfiguration(new ConfigurationBridge(settings)); + + @Before + public void preTest() throws Exception { + + MockitoAnnotations.initMocks(this); + settings.setProperty("sonar.auth.openshift.isEnabled", true); + settings.setProperty("kubernetes.service.account.dir", "src/test/resources"); + settings.setProperty("kubernetes.service", "http://127.0.0.1/"); + + OAuthConfig c = new OAuthConfig(config.getClientId(), config.getNamespace(), "http://local/callback/", null, + null, null, "code", null, null, new MockHttpClient()); + + oauth2Service2 = new OAuth20Service(scribeApi, c); + + when(scribeApi.createService(any())).thenReturn(oauth2Service2); + + when(scribeApi.getSignatureType()).thenReturn(OAuth2SignatureType.BEARER_AUTHORIZATION_REQUEST_HEADER_FIELD); + +// doReturn(new Response(200, "message", new HashMap(), new FileInputStream("src/test/resources/service_account_user.json"))) +// .when(oauth2Service).execute(argThat(req -> req.getUrl().equals("http://127.0.0.1/oapi/v1/users/~"))); +// +// doReturn(new Response(200, "message1", new HashMap(), new FileInputStream("src/test/resources/route.json"))) +// .when(oauth2Service).execute(argThat(req -> req.getUrl().equals("http://127.0.0.1/apis/route.openshift.io/v1/namespaces/sqube/routes/sonarqube"))); + testProvider = new OpenShiftIdentityProvider(config, scribeApi); + + } + + @Test + public void checkFields() { + + assertEquals("openshift", testProvider.getKey()); + assertEquals("OpenShift", testProvider.getName()); + assertEquals("/static/authopenshift/openshift.svg", testProvider.getDisplay().getIconPath()); + assertEquals("#666666", testProvider.getDisplay().getBackgroundColor()); + } + + @Test + public void isEnabled() { + settings.setProperty("sonar.auth.openshift.isEnabled", true); + assertTrue(testProvider.isEnabled()); + settings.setProperty("sonar.auth.openshift.isEnabled", false); + assertFalse(testProvider.isEnabled()); + } + + @Test + public void init() { + settings.setProperty("sonar.auth.openshift.isEnabled", true); + when(context.generateCsrfState()).thenReturn("state"); + when(scribeApi.getAuthorizationUrl(any(), any())).thenCallRealMethod(); + when(scribeApi.getAuthorizationBaseUrl()).thenReturn("http://localhost/authurl"); + + testProvider.init(context); + + verify(context).redirectTo("http://localhost/authurl?response_type=code&client_id=system%3Aserviceaccount%3Asqube%3Anull&redirect_uri=http%3A%2F%2Flocal%2Fcallback%2F"); + + } + + @Test + public void failToInitWhenDisabled() { + settings.setProperty("sonar.auth.openshift.isEnabled", false); + + thrown.expect(IllegalStateException.class); + thrown.expectMessage("OpenShift authentication is disabled"); + testProvider.init(context); + } +} \ No newline at end of file diff --git a/src/test/java/com/rhc/sonarqube/auth/openshift/test/MockHttpClient.java b/src/test/java/com/rhc/sonarqube/auth/openshift/test/MockHttpClient.java new file mode 100644 index 0000000..5574476 --- /dev/null +++ b/src/test/java/com/rhc/sonarqube/auth/openshift/test/MockHttpClient.java @@ -0,0 +1,75 @@ +package com.rhc.sonarqube.auth.openshift.test; + +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Future; + +import com.github.scribejava.core.httpclient.HttpClient; +import com.github.scribejava.core.model.OAuthAsyncRequestCallback; +import com.github.scribejava.core.model.OAuthRequest.ResponseConverter; +import com.github.scribejava.core.model.Response; +import com.github.scribejava.core.model.Verb; + +public class MockHttpClient implements HttpClient{ + + @Override + public void close() throws IOException { + // TODO Auto-generated method stub + + } + + @Override + public Future executeAsync(String userAgent, Map headers, Verb httpVerb, String completeUrl, + byte[] bodyContents, OAuthAsyncRequestCallback callback, ResponseConverter converter) { + // Auto-generated method stub + return null; + } + + @Override + public Future executeAsync(String userAgent, Map headers, Verb httpVerb, String completeUrl, + String bodyContents, OAuthAsyncRequestCallback callback, ResponseConverter converter) { + // Auto-generated method stub + return null; + } + + @Override + public Future executeAsync(String userAgent, Map headers, Verb httpVerb, String completeUrl, + File bodyContents, OAuthAsyncRequestCallback callback, ResponseConverter converter) { + // TODO Auto-generated method stub + return null; + } + + @Override + public Response execute(String userAgent, Map headers, Verb httpVerb, String completeUrl, + byte[] bodyContents) throws InterruptedException, ExecutionException, IOException { + + if(completeUrl.contentEquals("http://127.0.0.1/oapi/v1/users/~")) { + return new Response(200, "message", new HashMap(), + new FileInputStream("src/test/resources/service_account_user.json")); + } else if(completeUrl.contentEquals("http://127.0.0.1/apis/route.openshift.io/v1/namespaces/sqube/routes/sonarqube")) { + return new Response(200, "message1", new HashMap(), + new FileInputStream("src/test/resources/route.json")); + } + + return null; + } + + @Override + public Response execute(String userAgent, Map headers, Verb httpVerb, String completeUrl, + String bodyContents) throws InterruptedException, ExecutionException, IOException { + // TODO Auto-generated method stub + return null; + } + + @Override + public Response execute(String userAgent, Map headers, Verb httpVerb, String completeUrl, + File bodyContents) throws InterruptedException, ExecutionException, IOException { + // TODO Auto-generated method stub + return null; + } + +} diff --git a/src/test/resources/ca.crt b/src/test/resources/ca.crt new file mode 100644 index 0000000..9d2755a --- /dev/null +++ b/src/test/resources/ca.crt @@ -0,0 +1,21 @@ +-----BEGIN CERTIFICATE----- +MIIDbzCCAlcCAmm6MA0GCSqGSIb3DQEBCwUAMH0xCzAJBgNVBAYTAlVTMQswCQYD +VQQIDAJDQTEWMBQGA1UEBwwNU2FuIEZyYW5jaXNjbzEZMBcGA1UECgwQU3Ryb25n +TG9vcCwgSW5jLjESMBAGA1UECwwJU3Ryb25nT3BzMRowGAYDVQQDDBFjYS5zdHJv +bmdsb29wLmNvbTAeFw0xNTEyMDgyMzM1MzNaFw00MzA0MjQyMzM1MzNaMH0xCzAJ +BgNVBAYTAlVTMQswCQYDVQQIDAJDQTEWMBQGA1UEBwwNU2FuIEZyYW5jaXNjbzEZ +MBcGA1UECgwQU3Ryb25nTG9vcCwgSW5jLjESMBAGA1UECwwJU3Ryb25nT3BzMRow +GAYDVQQDDBFjYS5zdHJvbmdsb29wLmNvbTCCASIwDQYJKoZIhvcNAQEBBQADggEP +ADCCAQoCggEBANfj86jkvvYDjHBgiqWhk9Cj+bqiMq3MqnV0CBO4iuK33Fo6XssE +H+yVdXlIBFbFe6t655MdBVOR2Sfj7WqNh96vhu6PyDHiwcQlTaiLU6nhIed1J4Wv +lvnJHFmp8Wbtx5AgLT4UYu03ftvXEl2DLi3vhSL2tRM1ebXHB/KPbRWkb25DPX0P +foOHot3f2dgNe2x6kponf7E/QDmAu3s7Nlkfh+ryDhgGU7wocXEhXbprNqRqOGNo +xbXgUI+/9XDxYT/7Gn5LF/fPjtN+aB0SKMnTsDhprVlZie83mlqJ46fOOrR+vrsQ +mi/1m/TadrARtZoIExC/cQRdVM05EK4tUa8CAwEAATANBgkqhkiG9w0BAQsFAAOC +AQEAQ7k5WhyhDTIGYCNzRnrMHWSzGqa1y4tJMW06wafJNRqTm1cthq1ibc6Hfq5a +K10K0qMcgauRTfQ1MWrVCTW/KnJ1vkhiTOH+RvxapGn84gSaRmV6KZen0+gMsgae +KEGe/3Hn+PmDVV+PTamHgPACfpTww38WHIe/7Ce9gHfG7MZ8cKHNZhDy0IAYPln+ +YRwMLd7JNQffHAbWb2CE1mcea4H/12U8JZW5tHCF6y9V+7IuDzqwIrLKcW3lG17n +VUG6ODF/Ryqn3V5X+TL91YyXi6c34y34IpC7MQDV/67U7+5Bp5CfeDPWW2wVSrW+ +uGZtfEvhbNm6m2i4UNmpCXxUZQ== +-----END CERTIFICATE----- diff --git a/src/test/resources/namespace b/src/test/resources/namespace new file mode 100644 index 0000000..ff114a2 --- /dev/null +++ b/src/test/resources/namespace @@ -0,0 +1 @@ +sqube \ No newline at end of file diff --git a/src/test/resources/route.json b/src/test/resources/route.json new file mode 100644 index 0000000..9ab0599 --- /dev/null +++ b/src/test/resources/route.json @@ -0,0 +1,37 @@ +{ + "kind": "Route", + "apiVersion": "route.openshift.io/v1", + "metadata": { + "name": "sonarqube", + "namespace": "kevin-ci-cd", + "resourceVersion": "680137", + "annotations": { + "kubectl.kubernetes.io/last-applied-configuration": "{\"apiVersion\":\"route.openshift.io/v1\",\"kind\":\"Route\",\"metadata\":{\"annotations\":{},\"labels\":{\"app\":\"sonarqube\"},\"name\":\"sonarqube\",\"namespace\":\"sqube\"},\"spec\":{\"port\":{\"targetPort\":\"9000-tcp\"},\"tls\":{\"termination\":\"edge\"},\"to\":{\"kind\":\"Service\",\"name\":\"sonarqube\",\"weight\":100},\"wildcardPolicy\":\"None\"}}\n", + "openshift.io/host.generated": "true" + } + }, + "spec": { + "host": "sonarqube", + "to": { + "kind": "Service", + "name": "sonarqube", + "weight": 100 + }, + "port": { + "targetPort": "9000-tcp" + }, + "tls": { + "termination": "edge" + }, + "wildcardPolicy": "None" + }, + "status": { + "ingress": [ + { + "host": "sonarqube-route", + "routerName": "router", + "wildcardPolicy": "None" + } + ] + } +} \ No newline at end of file diff --git a/src/test/resources/service_account_user.json b/src/test/resources/service_account_user.json new file mode 100644 index 0000000..40395ad --- /dev/null +++ b/src/test/resources/service_account_user.json @@ -0,0 +1,13 @@ +{ + "kind": "User", + "apiVersion": "v1", + "metadata": { + "name": "system:serviceaccount:kevin-ci-cd:sonarqube" + }, + "identities": null, + "groups": [ + "system:authenticated", + "system:serviceaccounts", + "system:serviceaccounts:sonarproject" + ] + } \ No newline at end of file diff --git a/src/test/resources/token b/src/test/resources/token new file mode 100644 index 0000000..f0c692d --- /dev/null +++ b/src/test/resources/token @@ -0,0 +1 @@ +eyJhbGciOiJSUzI1NiIsImtpZCI6IiJ9.eyJpc3MiOiJrdWJlcm5ldGVzL3NlcnZpY2VhY2NvdW50Iiwia3ViZXJuZXRlcy5pby9zZXJ2aWNlYWNjb3VudC9uYW1lc3BhY2UiOiJrZXZpbi1jaS1jZCIsImt1YmVybmV0ZXMuaW8vc2VydmljZWFjY291bnQvc2VjcmV0Lm5hbWUiOiJzb25hcnF1YmUtdG9rZW4tYnB2OW0iLCJrdWJlcm5ldGVzLmlvL3NlcnZpY2VhY2NvdW50L3NlcnZpY2UtYWNjb3VudC5uYW1lIjoic29uYXJxdWJlIiwia3ViZXJuZXRlcy5pby9zZXJ2aWNlYWNjb3VudC9zZXJ2aWNlLWFjY291bnQudWlkIjoiOGY2ZjUyNGItYzc3OC0xMWU5LTlmOTYtZmExNjNlNTI5ODdmIiwic3ViIjoic3lzdGVtOnNlcnZpY2VhY2NvdW50OmtldmluLWNpLWNkOnNvbmFycXViZSJ9.lVrz1ChkJUQ_yodH7UIG3Q9fo4VLtTbdh_uVfGu_rMDmf6Rfkt3uImALo6VWsAxYMc1spoKv3x9JGBD1YYLUnZ3326wjSPSa6Lvwz6aL-NqOwQ4ebD2R9t39mcdc9cZi7xnRYTL5Rf5Mb299kVNFV6twDSjvZbjgxKEgx8PyH5EcEGCSooGDEpP1Pdj0oOs3GAnBVyGHvlfIN2sbcRt321o8lv1tND9AcFUv_jAlYPLuPGRALDYVISsY9ZNzrW98rr4Lu_WmA_QIbGbwyAVHwYhfOipESmNfOjK-N9qEY8H8YwhEq8Z2H-7_ZGSIdiumneao6vX6_6 \ No newline at end of file