From c8977d7175ff993df9a8575f4e1612e7ed3fa833 Mon Sep 17 00:00:00 2001 From: Stephan Schnabel Date: Fri, 3 Mar 2023 12:32:46 +0100 Subject: [PATCH] First draft of implementation (#1) * First draft of implementation Readme will follow * Rename id. * Exclude `commons-io` to disable build warnings * Simplify metrics and add documentation --- .github/CODEOWNERS | 4 + .github/dependabot.yml | 20 + .github/workflows/ci.yaml | 70 +++ .github/workflows/dependabot.yaml | 17 + .github/workflows/release.yaml | 34 ++ .markdownlint.yaml | 9 + .yamllint | 19 + LICENSE | 201 +++++++++ README.md | 75 +++- pom.xml | 409 ++++++++++++++++++ .../metrics/MicrometerEventListener.java | 32 ++ .../MicrometerEventListenerFactory.java | 42 ++ .../metrics/MicrometerEventListenerSpi.java | 32 ++ .../metrics/MicrometerEventRecorder.java | 51 +++ ...ycloak.events.EventListenerProviderFactory | 1 + .../org.keycloak.events.EventListenerSpi | 1 + .../kokuwa/keycloak/metrics/KeycloakIT.java | 66 +++ .../metrics/junit/KeycloakClient.java | 72 +++ .../metrics/junit/KeycloakExtension.java | 95 ++++ .../metrics/prometheus/Prometheus.java | 61 +++ .../metrics/prometheus/PrometheusClient.java | 19 + .../metrics/prometheus/PrometheusMetric.java | 13 + src/test/resources/test.properties | 3 + 23 files changed, 1345 insertions(+), 1 deletion(-) create mode 100644 .github/CODEOWNERS create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/ci.yaml create mode 100644 .github/workflows/dependabot.yaml create mode 100644 .github/workflows/release.yaml create mode 100644 .markdownlint.yaml create mode 100644 .yamllint create mode 100644 LICENSE create mode 100644 pom.xml create mode 100644 src/main/java/io/kokuwa/keycloak/metrics/MicrometerEventListener.java create mode 100644 src/main/java/io/kokuwa/keycloak/metrics/MicrometerEventListenerFactory.java create mode 100644 src/main/java/io/kokuwa/keycloak/metrics/MicrometerEventListenerSpi.java create mode 100644 src/main/java/io/kokuwa/keycloak/metrics/MicrometerEventRecorder.java create mode 100644 src/main/resources/META-INF/services/org.keycloak.events.EventListenerProviderFactory create mode 100644 src/main/resources/META-INF/services/org.keycloak.events.EventListenerSpi create mode 100644 src/test/java/io/kokuwa/keycloak/metrics/KeycloakIT.java create mode 100644 src/test/java/io/kokuwa/keycloak/metrics/junit/KeycloakClient.java create mode 100644 src/test/java/io/kokuwa/keycloak/metrics/junit/KeycloakExtension.java create mode 100644 src/test/java/io/kokuwa/keycloak/metrics/prometheus/Prometheus.java create mode 100644 src/test/java/io/kokuwa/keycloak/metrics/prometheus/PrometheusClient.java create mode 100644 src/test/java/io/kokuwa/keycloak/metrics/prometheus/PrometheusMetric.java create mode 100644 src/test/resources/test.properties diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..164cbad --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,4 @@ +# https://docs.github.com/en/github/creating-cloning-and-archiving-repositories/about-code-owners#codeowners-syntax +* @sschnabe @rpahli @fabian-schlegel @jschwarze @wistefan @monotek +.github/workflows/* @kokuwaio-bot +pom.xml @kokuwaio-bot diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..9984b24 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,20 @@ +version: 2 +updates: + - package-ecosystem: maven + directory: / + schedule: + interval: monthly + day: monday + # github parses time without quotes to int + # yamllint disable-line rule:quoted-strings + time: "09:00" + timezone: Europe/Berlin + - package-ecosystem: github-actions + directory: / + schedule: + interval: monthly + day: monday + # github parses time without quotes to int + # yamllint disable-line rule:quoted-strings + time: "09:00" + timezone: Europe/Berlin diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 0000000..f0a851e --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,70 @@ +name: CI + +on: + push: + branches: [main] + pull_request: {} + +jobs: + + yaml: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: ibiqlik/action-yamllint@v3 + with: + format: colored + strict: true + + markdown: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: avto-dev/markdown-lint@v1 + with: + args: /github/workspace + + javadoc: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-java@v3 + with: + distribution: temurin + java-version: 17 + cache: maven + - run: mvn -B -ntp dependency:go-offline + - run: mvn -B -ntp javadoc:javadoc-no-fork -Ddoclint=all + + checkstyle: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-java@v3 + with: + distribution: temurin + java-version: 17 + cache: maven + - run: mvn -B -ntp dependency:go-offline + - run: mvn -B -ntp checkstyle:check + + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-java@v3 + with: + distribution: temurin + java-version: 17 + cache: maven + server-id: sonatype-nexus + server-username: SERVER_USERNAME + server-password: SERVER_PASSWORD + - run: mvn -B -ntp dependency:go-offline + - run: mvn -B -ntp verify -Dcheckstyle.skip -Dmaven.test.redirectTestOutputToFile=false + if: ${{ github.ref != 'refs/heads/main' }} + - run: mvn -B -ntp deploy -Dcheckstyle.skip -Dmaven.test.redirectTestOutputToFile=false + if: ${{ github.ref == 'refs/heads/main' }} + env: + SERVER_USERNAME: ${{ secrets.SONATYPE_NEXUS_USERNAME }} + SERVER_PASSWORD: ${{ secrets.SONATYPE_NEXUS_PASSWORD }} diff --git a/.github/workflows/dependabot.yaml b/.github/workflows/dependabot.yaml new file mode 100644 index 0000000..ed63eca --- /dev/null +++ b/.github/workflows/dependabot.yaml @@ -0,0 +1,17 @@ +name: Dependabot + +on: pull_request_target + +jobs: + auto-merge: + runs-on: ubuntu-latest + if: ${{ github.actor == 'dependabot[bot]' }} + steps: + - run: gh pr review --approve "$PR_URL" + env: + PR_URL: ${{ github.event.pull_request.html_url }} + GITHUB_TOKEN: ${{ secrets.GIT_ACTION_TOKEN }} + - run: gh pr merge --auto --squash "$PR_URL" + env: + PR_URL: ${{ github.event.pull_request.html_url }} + GITHUB_TOKEN: ${{ secrets.GIT_ACTION_TOKEN }} diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml new file mode 100644 index 0000000..653e10d --- /dev/null +++ b/.github/workflows/release.yaml @@ -0,0 +1,34 @@ +name: Release + +on: workflow_dispatch + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + with: + token: ${{ secrets.GIT_ACTION_TOKEN }} + - uses: crazy-max/ghaction-import-gpg@v5 + with: + gpg_private_key: ${{ secrets.GPG_PRIVATE_KEY }} + passphrase: ${{ secrets.GPG_PASSPHRASE }} + git_user_signingkey: true + git_commit_gpgsign: true + - uses: actions/setup-java@v3 + with: + distribution: temurin + java-version: 17 + cache: maven + server-id: sonatype-nexus + server-username: SERVER_USERNAME + server-password: SERVER_PASSWORD + gpg-private-key: ${{ secrets.GPG_PRIVATE_KEY }} + gpg-passphrase: GPG_PASSPHRASE + - run: mvn -B -ntp dependency:go-offline + - run: mvn -B -ntp release:prepare + - run: mvn -B -ntp release:perform + env: + SERVER_USERNAME: ${{ secrets.SONATYPE_NEXUS_USERNAME }} + SERVER_PASSWORD: ${{ secrets.SONATYPE_NEXUS_PASSWORD }} + GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }} diff --git a/.markdownlint.yaml b/.markdownlint.yaml new file mode 100644 index 0000000..5f08047 --- /dev/null +++ b/.markdownlint.yaml @@ -0,0 +1,9 @@ +# Default state for all rules +default: true + +# MD009 - Trailing spaces +MD009: + strict: true + +# MD013 - Line length +MD013: false diff --git a/.yamllint b/.yamllint new file mode 100644 index 0000000..8011808 --- /dev/null +++ b/.yamllint @@ -0,0 +1,19 @@ +extends: default + +## see https://yamllint.readthedocs.io/en/stable/rules.html +rules: + + # no need for document start + document-start: disable + + # line length is not important + line-length: disable + + # force double quotes everywhere + quoted-strings: + quote-type: double + required: only-when-needed + + # allow everything on keys + truthy: + check-keys: 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 index 6d33139..3451ad6 100644 --- a/README.md +++ b/README.md @@ -1 +1,74 @@ -# keycloak-event-metrics +# Keycloak Event Metrics + +Provides metrics for Keycloak user/admin events. + +[![Apache License, Version 2.0, January 2004](https://img.shields.io/github/license/kokuwaio/keycloak-event-metrics.svg?label=License)](http://www.apache.org/licenses/) +[![Maven Central](https://img.shields.io/maven-central/v/io.kokuwa.keycloak/keycloak-event-metrics.svg?label=Maven%20Central)](https://central.sonatype.com/search?namespace=io.kokuwa.keycloak&q=keycloak-event-metrics) +[![CI](https://img.shields.io/github/actions/workflow/status/kokuwaio/keycloak-event-metrics/ci.yaml?branch=main&label=CI)](https://github.com/kokuwaio/keycloak-event-metrics/actions/workflows/ci.yaml?query=branch%3Amain) + +## What? + +Resuses micrometer from Quarkus distribution to add metrics for Keycloak for events. + +### User Events + +User events are added with key `keycloak_event_user_total` and tags: + +* `type`: [EventType](https://github.com/keycloak/keycloak/blob/main/server-spi-private/src/main/java/org/keycloak/events/EventType.java#L27) from [Event#type](https://github.com/keycloak/keycloak/blob/main/server-spi-private/src/main/java/org/keycloak/events/Event.java#L44) +* `realm`: realm id from [Event#realmId](https://github.com/keycloak/keycloak/blob/main/server-spi-private/src/main/java/org/keycloak/events/Event.java#L46) +* `client`: client id from [Event#clientId](https://github.com/keycloak/keycloak/blob/main/server-spi-private/src/main/java/org/keycloak/events/Event.java#L48) +* `error`: error from [Event#error](https://github.com/keycloak/keycloak/blob/main/server-spi-private/src/main/java/org/keycloak/events/Event.java#L56), only present for error types + +Examples: + +```txt +keycloak_event_user_total{client="test",realm="9039a0b5-e8c9-437a-a02e-9d91b04548a4",type="LOGIN",error="",} 2.0 +keycloak_event_user_total{client="test",realm="1fdb3465-1675-49e8-88ad-292e2f42ee72",type="LOGIN",error="",} 1.0 +keycloak_event_user_total{client="test",realm="1fdb3465-1675-49e8-88ad-292e2f42ee72",type="LOGIN_ERROR",error="invalid_user_credentials",} 1.0 +``` + +### Admin Events + +Admin events are added with key `keycloak_event_admin_total` and tags: + +* `realm`: realm id from [AdminEvent#realmId](https://github.com/keycloak/keycloak/blob/main/server-spi-private/src/main/java/org/keycloak/events/admin/AdminEvent.java#L44) +* `operation`: [OperationType](https://github.com/keycloak/keycloak/blob/main/server-spi-private/src/main/java/org/keycloak/events/admin/OperationType.java#L27) from [AdminEvent#operationType](https://github.com/keycloak/keycloak/blob/main/server-spi-private/src/main/java/org/keycloak/events/admin/AdminEvent.java#L53) +* `resource`: [ResourceType](https://github.com/keycloak/keycloak/blob/main/server-spi-private/src/main/java/org/keycloak/events/admin/ResourceType.java#L24) from [AdminEvent#resourceType](https://github.com/keycloak/keycloak/blob/main/server-spi-private/src/main/java/org/keycloak/events/admin/AdminEvent.java#L51) +* `error`: error from [AdminEvent#error](https://github.com/keycloak/keycloak/blob/main/server-spi-private/src/main/java/org/keycloak/events/admin/AdminEvent.java#L59), only present for error types + +Examples: + +```txt +keycloak_event_admin_total{error="",operation="CREATE",realm="1fdb3465-1675-49e8-88ad-292e2f42ee72",resource="USER",} 1.0 +keycloak_event_admin_total{error="",operation="CREATE",realm="9039a0b5-e8c9-437a-a02e-9d91b04548a4",resource="USER",} 1.0 +``` + +## Installation + +### Testcontainers + +For usage in [Testcontainers](https://www.testcontainers.org/) see [KeycloakExtension.java](src/test/java/io/kokuwa/keycloak/metrics/junit/KeycloakExtension.java#L57-L68) + +### Docker + +Dockerfile: + +```Dockerfile +FROM quay.io/keycloak/keycloak:21.0.1 + +ENV KEYCLOAK_ADMIN=admin +ENV KEYCLOAK_ADMIN_PASSWORD=password +ENV KC_HEALTH_ENABLED=true +ENV KC_METRICS_ENABLED=true +ENV KC_LOG_CONSOLE_COLOR=true + +ADD target/keycloak-event-metrics-0.0.1-SNAPSHOT.jar /opt/keycloak/providers +RUN /opt/keycloak/bin/kc.sh build +``` + +Run: + +```sh +docker build . --tag keycloak:metrics +docker run --rm -p8080 keycloak:metrics start-dev +``` diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..f223d76 --- /dev/null +++ b/pom.xml @@ -0,0 +1,409 @@ + + + 4.0.0 + + io.kokuwa.keycloak + keycloak-event-metrics + 0.1.0-SNAPSHOT + + Keycloak Metrics + Provides metrics for Keycloak user/admin events + https://github.com/kokuwaio/keycloak-event-metrics + 2023 + + Kokuwa.io + http://kokuwa.io + + + + Apache License 2.0 + https://www.apache.org/licenses/LICENSE-2.0 + + + + + + stephanschnabel + Stephan Schnabel + https://github.com/sschnabe + stephan@grayc.de + GrayC GmbH + https://grayc.de + + + + + https://github.com/kokuwaio/keycloak-event-metrics + scm:git:https://github.com/kokuwaio/keycloak-event-metrics.git + scm:git:https://github.com/kokuwaio/keycloak-event-metrics.git + HEAD + + + github + https://github.com/kokuwaio/keycloak-event-metrics/issues + + + github + https://github.com/kokuwaio/keycloak-event-metrics/actions + + + + sonatype-nexus + https://oss.sonatype.org/content/repositories/snapshots + + + sonatype-nexus + https://oss.sonatype.org/service/local/staging/deploy/maven2 + + + + + + + + + + UTF-8 + + 17 + 17 + true + true + true + true + + + + + + + + 3.2.1 + 3.2.0 + 3.11.0 + 3.5.0 + 3.1.0 + 3.0.1 + 3.1.0 + 3.3.0 + 1.0.0 + 3.0.0-M7 + 3.3.0 + 3.2.1 + 3.0.0-M9 + 1.2.0 + 1.6.13 + 10.8.0 + 0.5.6 + + + + 21.0.1 + 1.17.6 + + + + + + + + + org.keycloak + keycloak-parent + ${version.org.keycloak} + pom + import + + + + + org.testcontainers + testcontainers-bom + ${version.org.testcontainers} + pom + import + + + + + + + + + org.keycloak + keycloak-core + provided + + + org.keycloak + keycloak-server-spi + provided + + + org.keycloak + keycloak-server-spi-private + provided + + + org.keycloak + keycloak-quarkus-server + provided + + + com.openshift + openshift-restclient-java + + + + + org.keycloak + keycloak-admin-client + test + + + + + org.testcontainers + junit-jupiter + test + + + org.wildfly.client + wildfly-client-config + 1.0.1.Final + test + + + + + + + + ${project.basedir}/src/test/resources + true + + + + + + org.apache.maven.plugins + maven-checkstyle-plugin + ${version.org.apache.maven.plugins.checkstyle} + + checkstyle.xml + checkstyle-suppression.xml + true + + + + com.puppycrawl.tools + checkstyle + ${version.com.puppycrawl.tools.checkstyle} + + + io.kokuwa + maven-parent + ${version.io.kokuwa.checkstyle} + zip + checkstyle + + + + + org.apache.maven.plugins + maven-clean-plugin + ${version.org.apache.maven.plugins.clean} + + + org.apache.maven.plugins + maven-compiler-plugin + ${version.org.apache.maven.plugins.compiler} + + + org.apache.maven.plugins + maven-dependency-plugin + ${version.org.apache.maven.plugins.dependency} + + + org.apache.maven.plugins + maven-deploy-plugin + ${version.org.apache.maven.plugins.deploy} + + + org.apache.maven.plugins + maven-failsafe-plugin + ${version.org.apache.maven.plugins.surefire} + + true + ${maven.test.redirectTestOutputToFile} + + + + org.apache.maven.plugins + maven-gpg-plugin + ${version.org.apache.maven.plugins.gpg} + + + org.apache.maven.plugins + maven-install-plugin + ${version.org.apache.maven.plugins.install} + + + org.apache.maven.plugins + maven-jar-plugin + ${version.org.apache.maven.plugins.jar} + + + org.apache.maven.plugins + maven-javadoc-plugin + ${version.org.apache.maven.plugins.jar} + + + org.apache.maven.plugins + maven-release-plugin + ${version.org.apache.maven.plugins.release} + + @{project.version} + release + true + true + @{prefix} prepare release @{releaseLabel} [no ci] + + + + org.apache.maven.plugins + maven-source-plugin + ${version.org.apache.maven.plugins.source} + + + org.apache.maven.plugins + maven-resources-plugin + ${version.org.apache.maven.plugins.resources} + + UTF-8 + + + + org.apache.maven.plugins + maven-surefire-plugin + ${version.org.apache.maven.plugins.surefire} + + + org.codehaus.mojo + tidy-maven-plugin + ${version.org.codehaus.mojo.tidy} + + + org.sonatype.plugins + nexus-staging-maven-plugin + ${version.org.sonatype.plugins.nexus-staging} + + + + + + + + org.codehaus.mojo + tidy-maven-plugin + + + + check + + + + + + + + org.apache.maven.plugins + maven-checkstyle-plugin + + + + check + + + + + + + + org.apache.maven.plugins + maven-failsafe-plugin + + + + integration-test + verify + + + + + + + + + + + release + + + + + + org.apache.maven.plugins + maven-source-plugin + + + + jar-no-fork + + + + + + + + org.apache.maven.plugins + maven-javadoc-plugin + + + + jar + + + + + + + + org.apache.maven.plugins + maven-gpg-plugin + + + + sign + + + + + + + + org.sonatype.plugins + nexus-staging-maven-plugin + true + + sonatype-nexus + https://oss.sonatype.org/ + true + + + + + + + + diff --git a/src/main/java/io/kokuwa/keycloak/metrics/MicrometerEventListener.java b/src/main/java/io/kokuwa/keycloak/metrics/MicrometerEventListener.java new file mode 100644 index 0000000..d630739 --- /dev/null +++ b/src/main/java/io/kokuwa/keycloak/metrics/MicrometerEventListener.java @@ -0,0 +1,32 @@ +package io.kokuwa.keycloak.metrics; + +import org.keycloak.events.Event; +import org.keycloak.events.EventListenerProvider; +import org.keycloak.events.admin.AdminEvent; + +/** + * Listener for {@link Event} and {@link AdminEvent}. + * + * @author Stephan Schnabel + */ +public class MicrometerEventListener implements EventListenerProvider, AutoCloseable { + + private final MicrometerEventRecorder recorder; + + MicrometerEventListener(MicrometerEventRecorder recorder) { + this.recorder = recorder; + } + + @Override + public void onEvent(Event event) { + recorder.userEvent(event); + } + + @Override + public void onEvent(AdminEvent event, boolean includeRepresentation) { + recorder.adminEvent(event); + } + + @Override + public void close() {} +} diff --git a/src/main/java/io/kokuwa/keycloak/metrics/MicrometerEventListenerFactory.java b/src/main/java/io/kokuwa/keycloak/metrics/MicrometerEventListenerFactory.java new file mode 100644 index 0000000..9e25a21 --- /dev/null +++ b/src/main/java/io/kokuwa/keycloak/metrics/MicrometerEventListenerFactory.java @@ -0,0 +1,42 @@ +package io.kokuwa.keycloak.metrics; + +import javax.enterprise.inject.spi.CDI; + +import org.keycloak.Config.Scope; +import org.keycloak.events.EventListenerProvider; +import org.keycloak.events.EventListenerProviderFactory; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.KeycloakSessionFactory; + +import io.micrometer.core.instrument.MeterRegistry; + +/** + * Factory for {@link MicrometerEventListener}, uses {@link MeterRegistry} from CDI. + * + * @author Stephan Schnabel + */ +public class MicrometerEventListenerFactory implements EventListenerProviderFactory { + + private MicrometerEventRecorder recorder; + + @Override + public String getId() { + return "metrics-listener"; + } + + @Override + public void init(Scope config) {} + + @Override + public void postInit(KeycloakSessionFactory factory) { + recorder = new MicrometerEventRecorder(CDI.current().select(MeterRegistry.class).get()); + } + + @Override + public EventListenerProvider create(KeycloakSession session) { + return new MicrometerEventListener(recorder); + } + + @Override + public void close() {} +} diff --git a/src/main/java/io/kokuwa/keycloak/metrics/MicrometerEventListenerSpi.java b/src/main/java/io/kokuwa/keycloak/metrics/MicrometerEventListenerSpi.java new file mode 100644 index 0000000..49ec789 --- /dev/null +++ b/src/main/java/io/kokuwa/keycloak/metrics/MicrometerEventListenerSpi.java @@ -0,0 +1,32 @@ +package io.kokuwa.keycloak.metrics; + +import org.keycloak.events.EventListenerSpi; +import org.keycloak.provider.Provider; + +/** + * Factory for {@link MicrometerEventListener}. + * + * @author Stephan Schnabel + */ +public class MicrometerEventListenerSpi extends EventListenerSpi { + + @Override + public boolean isInternal() { + return false; + } + + @Override + public String getName() { + return "Micrometer Metrics Provider"; + } + + @Override + public Class getProviderClass() { + return MicrometerEventListener.class; + } + + @Override + public Class getProviderFactoryClass() { + return MicrometerEventListenerFactory.class; + } +} diff --git a/src/main/java/io/kokuwa/keycloak/metrics/MicrometerEventRecorder.java b/src/main/java/io/kokuwa/keycloak/metrics/MicrometerEventRecorder.java new file mode 100644 index 0000000..99320c6 --- /dev/null +++ b/src/main/java/io/kokuwa/keycloak/metrics/MicrometerEventRecorder.java @@ -0,0 +1,51 @@ +package io.kokuwa.keycloak.metrics; + +import java.util.Arrays; +import java.util.HashMap; +import java.util.Map; + +import org.keycloak.events.Event; +import org.keycloak.events.admin.AdminEvent; + +import io.micrometer.core.instrument.Counter; +import io.micrometer.core.instrument.MeterRegistry; + +/** + * Micrometer based recorder for events. + * + * @author Stephan Schnabel + */ +public class MicrometerEventRecorder { + + private final Map counters = new HashMap<>(); + private final MeterRegistry registry; + + MicrometerEventRecorder(MeterRegistry registry) { + this.registry = registry; + } + + void adminEvent(AdminEvent event) { + counter("keycloak_event_admin", + "realm", toBlankIfNull(event.getRealmId()), + "resource", toBlankIfNull(event.getResourceType()), + "operation", toBlankIfNull(event.getOperationType()), + "error", toBlankIfNull(event.getError())); + } + + void userEvent(Event event) { + counter("keycloak_event_user", + "realm", toBlankIfNull(event.getRealmId()), + "type", toBlankIfNull(event.getType()), + "client", toBlankIfNull(event.getClientId()), + "error", toBlankIfNull(event.getError())); + } + + private void counter(String counter, String... tags) { + counters.computeIfAbsent(counter + Arrays.toString(tags), string -> registry.counter(counter, tags)) + .increment(); + } + + private String toBlankIfNull(Object value) { + return value == null ? "" : value.toString(); + } +} diff --git a/src/main/resources/META-INF/services/org.keycloak.events.EventListenerProviderFactory b/src/main/resources/META-INF/services/org.keycloak.events.EventListenerProviderFactory new file mode 100644 index 0000000..a54f10d --- /dev/null +++ b/src/main/resources/META-INF/services/org.keycloak.events.EventListenerProviderFactory @@ -0,0 +1 @@ +io.kokuwa.keycloak.metrics.MicrometerEventListenerFactory diff --git a/src/main/resources/META-INF/services/org.keycloak.events.EventListenerSpi b/src/main/resources/META-INF/services/org.keycloak.events.EventListenerSpi new file mode 100644 index 0000000..d2b799f --- /dev/null +++ b/src/main/resources/META-INF/services/org.keycloak.events.EventListenerSpi @@ -0,0 +1 @@ +io.kokuwa.keycloak.metrics.MicrometerEventListenerSpi diff --git a/src/test/java/io/kokuwa/keycloak/metrics/KeycloakIT.java b/src/test/java/io/kokuwa/keycloak/metrics/KeycloakIT.java new file mode 100644 index 0000000..c3115fe --- /dev/null +++ b/src/test/java/io/kokuwa/keycloak/metrics/KeycloakIT.java @@ -0,0 +1,66 @@ +package io.kokuwa.keycloak.metrics; + +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.UUID; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.keycloak.events.EventType; + +import io.kokuwa.keycloak.metrics.junit.KeycloakClient; +import io.kokuwa.keycloak.metrics.junit.KeycloakExtension; +import io.kokuwa.keycloak.metrics.prometheus.Prometheus; + +@ExtendWith(KeycloakExtension.class) +public class KeycloakIT { + + @DisplayName("login and attempts") + @Test + void loginAndAttempts(KeycloakClient keycloak, Prometheus prometheus) { + + var realmName1 = UUID.randomUUID().toString(); + var username1 = UUID.randomUUID().toString(); + var password1 = UUID.randomUUID().toString(); + var realmName2 = UUID.randomUUID().toString(); + var username2 = UUID.randomUUID().toString(); + var password2 = UUID.randomUUID().toString(); + var realmId1 = keycloak.createRealm(realmName1); + var realmId2 = keycloak.createRealm(realmName2); + keycloak.createUser(realmName1, username1, password1); + keycloak.createUser(realmName2, username2, password2); + + prometheus.scrap(); + var loginBefore = prometheus.userEvent(EventType.LOGIN); + var loginBefore1 = prometheus.userEvent(EventType.LOGIN, realmId1); + var loginBefore2 = prometheus.userEvent(EventType.LOGIN, realmId2); + var loginErrorBefore = prometheus.userEvent(EventType.LOGIN_ERROR); + var loginErrorBefore1 = prometheus.userEvent(EventType.LOGIN_ERROR, realmId1); + var loginErrorBefore2 = prometheus.userEvent(EventType.LOGIN_ERROR, realmId2); + + assertTrue(keycloak.login(realmName1, username1, password1)); + assertTrue(keycloak.login(realmName1, username1, password1)); + assertTrue(keycloak.login(realmName2, username2, password2)); + assertFalse(keycloak.login(realmName2, username2, "nope")); + + prometheus.scrap(); + var loginAfter = prometheus.userEvent(EventType.LOGIN); + var loginAfter1 = prometheus.userEvent(EventType.LOGIN, realmId1); + var loginAfter2 = prometheus.userEvent(EventType.LOGIN, realmId2); + var loginErrorAfter = prometheus.userEvent(EventType.LOGIN_ERROR); + var loginErrorAfter1 = prometheus.userEvent(EventType.LOGIN_ERROR, realmId1); + var loginErrorAfter2 = prometheus.userEvent(EventType.LOGIN_ERROR, realmId2); + + assertAll("prometheus", + () -> assertEquals(loginBefore + 3, loginAfter, "login success total"), + () -> assertEquals(loginBefore1 + 2, loginAfter1, "login success #1"), + () -> assertEquals(loginBefore2 + 1, loginAfter2, "login success #2"), + () -> assertEquals(loginErrorBefore + 1, loginErrorAfter, "login failure total"), + () -> assertEquals(loginErrorBefore1 + 0, loginErrorAfter1, "login failure #1"), + () -> assertEquals(loginErrorBefore2 + 1, loginErrorAfter2, "login failure #2")); + } +} diff --git a/src/test/java/io/kokuwa/keycloak/metrics/junit/KeycloakClient.java b/src/test/java/io/kokuwa/keycloak/metrics/junit/KeycloakClient.java new file mode 100644 index 0000000..6507440 --- /dev/null +++ b/src/test/java/io/kokuwa/keycloak/metrics/junit/KeycloakClient.java @@ -0,0 +1,72 @@ +package io.kokuwa.keycloak.metrics.junit; + +import java.util.List; +import java.util.Map; + +import javax.ws.rs.NotAuthorizedException; +import javax.ws.rs.core.MultivaluedHashMap; + +import org.keycloak.OAuth2Constants; +import org.keycloak.admin.client.Keycloak; +import org.keycloak.admin.client.token.TokenService; +import org.keycloak.representations.idm.ClientRepresentation; +import org.keycloak.representations.idm.CredentialRepresentation; +import org.keycloak.representations.idm.RealmRepresentation; +import org.keycloak.representations.idm.UserRepresentation; + +/** + * Client for keycloak. + * + * @author Stephan Schnabel + */ +public class KeycloakClient { + + private final Keycloak keycloak; + private final TokenService token; + + KeycloakClient(Keycloak keycloak, TokenService token) { + this.keycloak = keycloak; + this.token = token; + } + + public String createRealm(String realmName) { + var client = new ClientRepresentation(); + client.setClientId("test"); + client.setPublicClient(true); + client.setDirectAccessGrantsEnabled(true); + var realm = new RealmRepresentation(); + realm.setEnabled(true); + realm.setRealm(realmName); + realm.setEventsListeners(List.of("metrics-listener")); + realm.setClients(List.of(client)); + keycloak.realms().create(realm); + return keycloak.realms().realm(realmName).toRepresentation().getId(); + } + + public void createUser(String realmName, String username, String password) { + var credential = new CredentialRepresentation(); + credential.setType(CredentialRepresentation.PASSWORD); + credential.setValue(password); + credential.setTemporary(false); + var user = new UserRepresentation(); + user.setEnabled(true); + user.setEmail(username + "@example.org"); + user.setEmailVerified(true); + user.setUsername(username); + user.setCredentials(List.of(credential)); + keycloak.realms().realm(realmName).users().create(user); + } + + public boolean login(String realmName, String username, String password) { + try { + token.grantToken(realmName, new MultivaluedHashMap<>(Map.of( + OAuth2Constants.CLIENT_ID, "test", + OAuth2Constants.GRANT_TYPE, OAuth2Constants.PASSWORD, + OAuth2Constants.USERNAME, username, + OAuth2Constants.PASSWORD, password))); + return true; + } catch (NotAuthorizedException e) { + return false; + } + } +} diff --git a/src/test/java/io/kokuwa/keycloak/metrics/junit/KeycloakExtension.java b/src/test/java/io/kokuwa/keycloak/metrics/junit/KeycloakExtension.java new file mode 100644 index 0000000..31ffe6c --- /dev/null +++ b/src/test/java/io/kokuwa/keycloak/metrics/junit/KeycloakExtension.java @@ -0,0 +1,95 @@ +package io.kokuwa.keycloak.metrics.junit; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.io.IOException; +import java.time.Duration; +import java.util.Properties; +import java.util.Set; + +import javax.ws.rs.client.ClientBuilder; + +import org.junit.jupiter.api.extension.BeforeAllCallback; +import org.junit.jupiter.api.extension.ExtensionContext; +import org.junit.jupiter.api.extension.ParameterContext; +import org.junit.jupiter.api.extension.ParameterResolver; +import org.keycloak.admin.client.Keycloak; +import org.keycloak.admin.client.token.TokenService; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.utility.MountableFile; + +import io.kokuwa.keycloak.metrics.prometheus.Prometheus; +import io.kokuwa.keycloak.metrics.prometheus.PrometheusClient; + +/** + * JUnit extension to start keycloak. + * + * @author Stephan Schnabel + */ +public class KeycloakExtension implements BeforeAllCallback, ParameterResolver { + + private static KeycloakClient client; + private static Prometheus prometheus; + + @Override + public void beforeAll(ExtensionContext context) throws Exception { + + if (client != null) { + return; + } + + // get test properties + + var properties = new Properties(); + try { + properties.load(getClass().getResourceAsStream("/test.properties")); + } catch (IOException e) { + throw new Exception("Failed to read properties", e); + } + var version = properties.getProperty("version"); + var jar = properties.getProperty("jar"); + var timeout = properties.getProperty("timeout"); + + // create and start container + + @SuppressWarnings("resource") + var container = new GenericContainer<>("quay.io/keycloak/keycloak:" + version) + .withEnv("KEYCLOAK_ADMIN", "admin") + .withEnv("KEYCLOAK_ADMIN_PASSWORD", "password") + .withEnv("KC_LOG_CONSOLE_COLOR", "true") + .withEnv("KC_HEALTH_ENABLED", "true") + .withEnv("KC_METRICS_ENABLED", "true") + .withCopyFileToContainer(MountableFile.forHostPath(jar), "/opt/keycloak/providers/metrics.jar") + .withLogConsumer(out -> System.out.print(out.getUtf8String())) + .withExposedPorts(8080) + .withStartupTimeout(Duration.parse(timeout)) + .waitingFor(Wait.forHttp("/health").forPort(8080)) + .withCommand("start-dev"); + try { + container.start(); + } catch (RuntimeException e) { + throw new Exception("Failed to start keycloak", e); + } + + // create client for keycloak container + + var url = "http://" + container.getHost() + ":" + container.getMappedPort(8080); + var keycloak = Keycloak.getInstance(url, "master", "admin", "password", "admin-cli"); + assertEquals(version, keycloak.serverInfo().getInfo().getSystemInfo().getVersion(), "version invalid"); + var target = ClientBuilder.newClient().target(url); + var token = Keycloak.getClientProvider().targetProxy(target, TokenService.class); + prometheus = new Prometheus(Keycloak.getClientProvider().targetProxy(target, PrometheusClient.class)); + client = new KeycloakClient(keycloak, token); + } + + @Override + public boolean supportsParameter(ParameterContext parameterContext, ExtensionContext extensionContext) { + return Set.of(KeycloakClient.class, Prometheus.class).contains(parameterContext.getParameter().getType()); + } + + @Override + public Object resolveParameter(ParameterContext parameterContext, ExtensionContext extensionContext) { + return parameterContext.getParameter().getType().equals(KeycloakClient.class) ? client : prometheus; + } +} diff --git a/src/test/java/io/kokuwa/keycloak/metrics/prometheus/Prometheus.java b/src/test/java/io/kokuwa/keycloak/metrics/prometheus/Prometheus.java new file mode 100644 index 0000000..d249edd --- /dev/null +++ b/src/test/java/io/kokuwa/keycloak/metrics/prometheus/Prometheus.java @@ -0,0 +1,61 @@ +package io.kokuwa.keycloak.metrics.prometheus; + +import java.util.HashSet; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import org.keycloak.events.EventType; + +/** + * Client to access Prometheus metric values: + * + * @author Stephan Schnabel + */ +public class Prometheus { + + private final Set state = new HashSet<>(); + private final PrometheusClient client; + + public Prometheus(PrometheusClient client) { + this.client = client; + } + + public int userEvent(EventType type) { + return state.stream() + .filter(metric -> Objects.equals(metric.name(), "keycloak_event_user_total")) + .filter(metric -> Objects.equals(metric.tags().get("type"), type.toString())) + .mapToInt(metric -> metric.value().intValue()) + .sum(); + } + + public int userEvent(EventType type, String realmName) { + return state.stream() + .filter(metric -> Objects.equals(metric.name(), "keycloak_event_user_total")) + .filter(metric -> Objects.equals(metric.tags().get("type"), type.toString())) + .filter(metric -> Objects.equals(metric.tags().get("realm"), realmName)) + .mapToInt(metric -> metric.value().intValue()) + .sum(); + } + + public void scrap() { + state.clear(); + Stream.of(client.scrap().split("[\\r\\n]+")) + .filter(line -> !line.startsWith("#")) + .filter(line -> line.startsWith("keycloak")) + .map(line -> { + var name = line.substring(0, line.contains("{") ? line.indexOf("{") : line.lastIndexOf(" ")); + var tags = line.contains("{") + ? Stream.of(line.substring(line.indexOf("{") + 1, line.indexOf("}")).split(",")) + .map(tag -> tag.split("=")) + .filter(tag -> tag.length >= 2) + .collect(Collectors.toMap(tag -> tag[0], tag -> tag[1].replace("\"", ""))) + : Map.of(); + var value = Double.parseDouble(line.substring(line.lastIndexOf(" "))); + return new PrometheusMetric(name, tags, value); + }) + .forEach(state::add); + } +} diff --git a/src/test/java/io/kokuwa/keycloak/metrics/prometheus/PrometheusClient.java b/src/test/java/io/kokuwa/keycloak/metrics/prometheus/PrometheusClient.java new file mode 100644 index 0000000..2355f3f --- /dev/null +++ b/src/test/java/io/kokuwa/keycloak/metrics/prometheus/PrometheusClient.java @@ -0,0 +1,19 @@ +package io.kokuwa.keycloak.metrics.prometheus; + +import javax.ws.rs.Consumes; +import javax.ws.rs.GET; +import javax.ws.rs.Path; +import javax.ws.rs.core.MediaType; + +/** + * JAX-RS client for prometheus endpoint. + * + * @author Stephan Schnabel + */ +public interface PrometheusClient { + + @GET + @Path("/metrics") + @Consumes(MediaType.TEXT_PLAIN) + String scrap(); +} diff --git a/src/test/java/io/kokuwa/keycloak/metrics/prometheus/PrometheusMetric.java b/src/test/java/io/kokuwa/keycloak/metrics/prometheus/PrometheusMetric.java new file mode 100644 index 0000000..a79f6c8 --- /dev/null +++ b/src/test/java/io/kokuwa/keycloak/metrics/prometheus/PrometheusMetric.java @@ -0,0 +1,13 @@ +package io.kokuwa.keycloak.metrics.prometheus; + +import java.util.Map; + +/** + * Represents a parsed Prometheus line. + * + * @author Stephan Schnabel + * @param name Metric name + * @param tags Tags for this metriv value + * @param value Metric value + */ +public record PrometheusMetric(String name, Map tags, Double value) {} diff --git a/src/test/resources/test.properties b/src/test/resources/test.properties new file mode 100644 index 0000000..d66d19f --- /dev/null +++ b/src/test/resources/test.properties @@ -0,0 +1,3 @@ +version=${version.org.keycloak} +timeout=PT5m +jar=${project.build.directory}/${project.build.finalName}.jar