diff --git a/.attach_pid308475 b/.attach_pid308475 new file mode 100644 index 0000000..e69de29 diff --git a/.github/changelog.yaml b/.github/changelog.yaml new file mode 100644 index 0000000..0a5526e --- /dev/null +++ b/.github/changelog.yaml @@ -0,0 +1,13 @@ +sections: + - title: Major changes + labels: + - "release/super-feature" + - title: Complete changelog + labels: + - "bug" + - "enhancement" + - "dependencies" +template: | + {{ range $section := .Sections }}{{ if $section.Items }}### {{ $section.GetTitle }}{{ range $item := $section.Items }} + * [#{{ $item.GetID }}]({{ $item.GetURL }}) - {{ $item.GetTitle }}{{ end }}{{ end }} + {{ end }} \ No newline at end of file diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..543ce22 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,14 @@ +version: 2 +updates: + - package-ecosystem: maven + directory: "/" + schedule: + interval: daily + labels: + - dependencies + - package-ecosystem: "docker" + directory: "/src/main/docker" + schedule: + interval: daily + labels: + - docker-image \ No newline at end of file diff --git a/.github/workflows/build-branch.yml b/.github/workflows/build-branch.yml new file mode 100644 index 0000000..7270213 --- /dev/null +++ b/.github/workflows/build-branch.yml @@ -0,0 +1,13 @@ +name: Build Feature Branch + +on: + push: + branches: + - '**' + - '!main' + - '!fix/[0-9]+.[0-9]+.x' + +jobs: + branch: + uses: onecx/ci-quarkus/.github/workflows/build-branch.yml@v1 + secrets: inherit \ No newline at end of file diff --git a/.github/workflows/build-pr.yml b/.github/workflows/build-pr.yml new file mode 100644 index 0000000..acff155 --- /dev/null +++ b/.github/workflows/build-pr.yml @@ -0,0 +1,9 @@ +name: Build Pull Request + +on: + pull_request: + +jobs: + pr: + uses: onecx/ci-quarkus/.github/workflows/build-pr.yml@v1 + secrets: inherit \ No newline at end of file diff --git a/.github/workflows/build-release.yml b/.github/workflows/build-release.yml new file mode 100644 index 0000000..b04a59a --- /dev/null +++ b/.github/workflows/build-release.yml @@ -0,0 +1,9 @@ +name: Build Release +on: + push: + tags: + - '**' +jobs: + release: + uses: onecx/ci-quarkus/.github/workflows/build-release.yml@v1 + secrets: inherit \ No newline at end of file diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..71d9db1 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,15 @@ +name: Build + +on: + workflow_dispatch: + push: + branches: + - 'main' + - 'fix/[0-9]+.[0-9]+.x' + +jobs: + build: + uses: onecx/ci-quarkus/.github/workflows/build.yml@v1 + secrets: inherit + with: + helmEventTargetRepository: onecx/onecx-tenant \ No newline at end of file diff --git a/.github/workflows/create-fix-branch.yml b/.github/workflows/create-fix-branch.yml new file mode 100644 index 0000000..92af624 --- /dev/null +++ b/.github/workflows/create-fix-branch.yml @@ -0,0 +1,7 @@ +name: Create Fix Branch +on: + workflow_dispatch: +jobs: + fix: + uses: onecx/ci-common/.github/workflows/create-fix-branch.yml@v1 + secrets: inherit \ No newline at end of file diff --git a/.github/workflows/create-new-build.yml b/.github/workflows/create-new-build.yml new file mode 100644 index 0000000..5051e3c --- /dev/null +++ b/.github/workflows/create-new-build.yml @@ -0,0 +1,9 @@ +name: Create new build + +on: + workflow_dispatch: + +jobs: + build: + uses: onecx/ci-common/.github/workflows/create-new-build.yml@v1 + secrets: inherit \ No newline at end of file diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml new file mode 100644 index 0000000..c97eb42 --- /dev/null +++ b/.github/workflows/create-release.yml @@ -0,0 +1,7 @@ +name: Create Release Version +on: + workflow_dispatch: +jobs: + release: + uses: onecx/ci-common/.github/workflows/create-release.yml@v1 + secrets: inherit \ No newline at end of file diff --git a/.github/workflows/documentation.yml b/.github/workflows/documentation.yml new file mode 100644 index 0000000..e43d7dc --- /dev/null +++ b/.github/workflows/documentation.yml @@ -0,0 +1,10 @@ +name: Update documentation +on: + push: + branches: [ main ] + paths: + - 'docs/**' +jobs: + release: + uses: onecx/ci-common/.github/workflows/documentation.yml@v1 + secrets: inherit diff --git a/.github/workflows/sonar-pr.yml b/.github/workflows/sonar-pr.yml new file mode 100644 index 0000000..02c3e1e --- /dev/null +++ b/.github/workflows/sonar-pr.yml @@ -0,0 +1,12 @@ +name: Sonar Pull Request + +on: + workflow_run: + workflows: ["Build Pull Request"] + types: + - completed + +jobs: + pr: + uses: onecx/ci-quarkus/.github/workflows/quarkus-pr-sonar.yml@v1 + secrets: inherit \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1b89851 --- /dev/null +++ b/.gitignore @@ -0,0 +1,48 @@ +#Maven +target/ +pom.xml.tag +pom.xml.releaseBackup +pom.xml.versionsBackup +release.properties +.flattened-pom.xml + +# Eclipse +.project +.classpath +.settings/ +bin/ + +# IntelliJ +.idea +*.ipr +*.iml +*.iws + +# NetBeans +nb-configuration.xml + +# Visual Studio Code +.vscode +.factorypath + +# OSX +.DS_Store + +# Vim +*.swp +*.swo + +# patch +*.orig +*.rej + +# Local environment +.env + +# Plugin directory +/.quarkus/cli/plugins/ + +# Chart +Chart.lock +local.values.yaml +src/main/helm/charts diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..d9d996b --- /dev/null +++ b/pom.xml @@ -0,0 +1,148 @@ + + + + 4.0.0 + + + org.tkit.onecx + onecx-quarkus3-parent + 0.32.0 + + + onecx-iam-kc-client-operator + 999-SNAPSHOT + + + + io.quarkiverse.operatorsdk + quarkus-operator-sdk-bundle-generator + + + io.quarkiverse.operatorsdk + quarkus-operator-sdk + + + + + org.tkit.quarkus.lib + tkit-quarkus-log-cdi + + + org.tkit.quarkus.lib + tkit-quarkus-log-rs + + + org.tkit.quarkus.lib + tkit-quarkus-log-json + + + + + io.quarkus + quarkus-arc + + + io.quarkus + quarkus-micrometer-registry-prometheus + + + io.quarkus + quarkus-opentelemetry + + + io.quarkus + quarkus-keycloak-admin-client-reactive + + + io.quarkus + quarkus-rest-client-reactive + + + io.quarkus + quarkus-rest-client-reactive-jackson + + + + + org.mapstruct + mapstruct + + + + io.quarkus + quarkus-oidc + provided + + + + + io.quarkus + quarkus-junit5 + test + + + io.swagger.parser.v3 + swagger-parser + test + + + io.quarkus + quarkus-junit5-mockito + test + + + io.quarkus + quarkus-test-keycloak-server + test + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + ${maven.compiler-plugin.version} + + + -parameters + -Amapstruct.defaultComponentModel=jakarta-cdi + -Amapstruct.defaultInjectionStrategy=constructor + -Amapstruct.unmappedTargetPolicy=ERROR + -Amapstruct.unmappedSourcePolicy=IGNORE + + + + org.hibernate + hibernate-jpamodelgen + ${quarkus.hibernate-orm.version} + + + io.quarkus + quarkus-panache-common + ${quarkus.version} + + + org.mapstruct + mapstruct-processor + ${mapstruct.version} + + + org.projectlombok + lombok + ${projectlombok.version} + + + io.quarkus + quarkus-extension-processor + ${quarkus.version} + + + + + + + + + diff --git a/src/main/docker/Dockerfile.jvm b/src/main/docker/Dockerfile.jvm new file mode 100644 index 0000000..327bf74 --- /dev/null +++ b/src/main/docker/Dockerfile.jvm @@ -0,0 +1,7 @@ +FROM ghcr.io/onecx/docker-quarkus-jvm:0.4.0 + +COPY --chown=185 target/quarkus-app/lib/ /deployments/lib/ +COPY --chown=185 target/quarkus-app/*.jar /deployments/ +COPY --chown=185 target/quarkus-app/app/ /deployments/app/ +COPY --chown=185 target/quarkus-app/quarkus/ /deployments/quarkus/ +USER 185 \ No newline at end of file diff --git a/src/main/docker/Dockerfile.native b/src/main/docker/Dockerfile.native new file mode 100644 index 0000000..7aaeff9 --- /dev/null +++ b/src/main/docker/Dockerfile.native @@ -0,0 +1,3 @@ +FROM ghcr.io/onecx/docker-quarkus-native:0.2.0-rc.1 + +COPY --chown=1001:root target/*-runner /work/application \ No newline at end of file diff --git a/src/main/helm/Chart.yaml b/src/main/helm/Chart.yaml new file mode 100644 index 0000000..37abaae --- /dev/null +++ b/src/main/helm/Chart.yaml @@ -0,0 +1,17 @@ +apiVersion: v2 +name: onecx-iam-kc-client-operator +version: 0.0.0 +appVersion: 0.0.0 +description: Onecx IAM KC client operator +keywords: + - keycloak client +sources: + - https://github.com/onecx/onecx-iam-kc-client-operator +maintainers: + - name: Tkit Developer + email: tkit_dev@1000kit.org +dependencies: + - name: helm-quarkus-app + alias: app + version: ^0 + repository: oci://ghcr.io/onecx/charts diff --git a/src/main/helm/crds/keycloakclients.onecx.tkit.org-v1.yml b/src/main/helm/crds/keycloakclients.onecx.tkit.org-v1.yml new file mode 100644 index 0000000..bb952b5 --- /dev/null +++ b/src/main/helm/crds/keycloakclients.onecx.tkit.org-v1.yml @@ -0,0 +1,94 @@ +# Generated by Fabric8 CRDGenerator, manual edits might get overwritten! +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: keycloakclients.onecx.tkit.org +spec: + group: onecx.tkit.org + names: + kind: KeycloakClient + plural: keycloakclients + singular: keycloakclient + scope: Namespaced + versions: + - name: v1 + schema: + openAPIV3Schema: + properties: + spec: + properties: + kcConfig: + properties: + attributes: + additionalProperties: + type: string + type: object + bearerOnly: + type: boolean + clientAuthenticatorType: + type: string + clientId: + type: string + defaultClientScopes: + items: + type: string + type: array + description: + type: string + directAccessGrantsEnabled: + type: boolean + enabled: + type: boolean + implicitFlowEnabled: + type: boolean + optionalClientScopes: + items: + type: string + type: array + protocol: + type: string + publicClient: + type: boolean + redirectUris: + items: + type: string + type: array + secret: + type: string + serviceAccountsEnabled: + type: boolean + standardFlowEnabled: + type: boolean + webOrigins: + items: + type: string + type: array + type: object + realm: + type: string + type: + type: string + type: object + status: + properties: + clientId: + type: string + message: + type: string + observedGeneration: + type: integer + response-code: + type: integer + status: + enum: + - CREATED + - ERROR + - UNDEFINED + - UPDATED + type: string + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/src/main/helm/templates/kc-client-cluster-role-binding.yaml b/src/main/helm/templates/kc-client-cluster-role-binding.yaml new file mode 100644 index 0000000..1c2995f --- /dev/null +++ b/src/main/helm/templates/kc-client-cluster-role-binding.yaml @@ -0,0 +1,43 @@ +{{ if eq $.Values.watchNamespaces "JOSDK_WATCH_CURRENT" }} +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: {{ .Release.Name }}-{{ .Values.app.name }}-role-binding +roleRef: + kind: ClusterRole + apiGroup: rbac.authorization.k8s.io + name: {{ .Release.Name }}-{{ .Values.app.name }}-cluster-role +subjects: + - kind: ServiceAccount + name: {{ .Release.Name }}-{{ .Values.app.name }} +{{ else if eq $.Values.watchNamespaces "JOSDK_ALL_NAMESPACES" }} +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: {{ .Release.Name }}-{{ .Values.app.name }}-role-binding +roleRef: + kind: ClusterRole + apiGroup: rbac.authorization.k8s.io + name: {{ .Release.Name }}-{{ .Values.app.name }}-cluster-role +subjects: + - kind: ServiceAccount + name: {{ .Release.Name }}-{{ .Values.app.name }} + namespace: {{ $.Release.Namespace }} +{{ else }} +{{ range $anamespace := ( split "," $.Values.watchNamespaces ) }} +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: {{ .Release.Name }}-{{ .Values.app.name }}-role-binding + namespace: {{ $anamespace }} +roleRef: + kind: ClusterRole + apiGroup: rbac.authorization.k8s.io + name: {{ .Release.Name }}-{{ .Values.app.name }}-cluster-role +subjects: + - kind: ServiceAccount + name: {{ .Release.Name }}-{{ .Values.app.name }} + namespace: {{ $.Release.Namespace }} +--- +{{- end }} +{{- end }} \ No newline at end of file diff --git a/src/main/helm/templates/kc-client-cluster-role.yaml b/src/main/helm/templates/kc-client-cluster-role.yaml new file mode 100644 index 0000000..3bd5b9e --- /dev/null +++ b/src/main/helm/templates/kc-client-cluster-role.yaml @@ -0,0 +1,19 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: {{ .Release.Name }}-{{ .Values.app.name }}-cluster-role +rules: + - apiGroups: + - "onecx.tkit.org" + resources: + - "keycloakclients" + - "keycloakclients/status" + - "keycloakclients/finalizers" + verbs: + - "get" + - "list" + - "watch" + - "patch" + - "update" + - "create" + - "delete" \ No newline at end of file diff --git a/src/main/helm/templates/operator-cluster-role-binding.yaml b/src/main/helm/templates/operator-cluster-role-binding.yaml new file mode 100644 index 0000000..5e739ff --- /dev/null +++ b/src/main/helm/templates/operator-cluster-role-binding.yaml @@ -0,0 +1,12 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: {{ .Release.Name }}-{{ .Values.app.name }}-validating-role-binding +roleRef: + kind: ClusterRole + apiGroup: rbac.authorization.k8s.io + name: {{ .Release.Name }}-{{ .Values.app.name }}-validating-cluster-role +subjects: + - kind: ServiceAccount + name: {{ .Release.Name }}-{{ .Values.app.name }} + namespace: {{ .Release.Namespace }} diff --git a/src/main/helm/templates/operator-cluster-role.yaml b/src/main/helm/templates/operator-cluster-role.yaml new file mode 100644 index 0000000..4d51a68 --- /dev/null +++ b/src/main/helm/templates/operator-cluster-role.yaml @@ -0,0 +1,12 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: {{ .Release.Name }}-{{ .Values.app.name }}-validating-cluster-role +rules: + - apiGroups: + - apiextensions.k8s.io + resources: + - customresourcedefinitions + verbs: + - get + - list diff --git a/src/main/helm/templates/service-account.yaml b/src/main/helm/templates/service-account.yaml new file mode 100644 index 0000000..56ccce8 --- /dev/null +++ b/src/main/helm/templates/service-account.yaml @@ -0,0 +1,4 @@ +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ .Release.Name }}-{{ .Values.app.name }} \ No newline at end of file diff --git a/src/main/helm/values.yaml b/src/main/helm/values.yaml new file mode 100644 index 0000000..a6e3620 --- /dev/null +++ b/src/main/helm/values.yaml @@ -0,0 +1,17 @@ +app: + name: operator + image: + repository: "onecx/onecx-iam-kc-client-operator" + env: + # See watchNamespaces + "QUARKUS_OPERATOR_SDK_CONTROLLERS_KC_NAMESPACES": "JOSDK_WATCH_CURRENT" + envCustom: + - name: KUBERNETES_NAMESPACE + valueFrom: + fieldRef: + fieldPath: metadata.namespace + serviceAccount: + enabled: true + +# Values: JOSDK_WATCH_CURRENT, JOSDK_ALL_NAMESPACES or comma separated list of namespaces +watchNamespaces: "JOSDK_WATCH_CURRENT" diff --git a/src/main/java/org/tkit/onecx/iam/kc/client/operator/KCConfig.java b/src/main/java/org/tkit/onecx/iam/kc/client/operator/KCConfig.java new file mode 100644 index 0000000..93a93b0 --- /dev/null +++ b/src/main/java/org/tkit/onecx/iam/kc/client/operator/KCConfig.java @@ -0,0 +1,175 @@ +package org.tkit.onecx.iam.kc.client.operator; + +import java.util.List; +import java.util.Map; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public class KCConfig { + + @JsonProperty(required = true) + private String clientId; + + private String description; + + private Boolean enabled; + + private String clientAuthenticatorType; + + private String password; + + private List redirectUris; + + private List webOrigins; + + private Boolean bearerOnly; + + private Boolean standardFlowEnabled; + private Boolean implicitFlowEnabled; + private Boolean directAccessGrantsEnabled; + private Boolean serviceAccountsEnabled; + private Boolean publicClient; + + private String protocol; + + private Map attributes; + + private List defaultClientScopes; + private List optionalClientScopes; + + public String getClientId() { + return clientId; + } + + public void setClientId(String clientId) { + this.clientId = clientId; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + public Boolean getEnabled() { + return enabled; + } + + public void setEnabled(Boolean enabled) { + this.enabled = enabled; + } + + public String getClientAuthenticatorType() { + return clientAuthenticatorType; + } + + public void setClientAuthenticatorType(String clientAuthenticatorType) { + this.clientAuthenticatorType = clientAuthenticatorType; + } + + public String getPassword() { + return password; + } + + public void setPassword(String password) { + this.password = password; + } + + public List getRedirectUris() { + return redirectUris; + } + + public void setRedirectUris(List redirectUris) { + this.redirectUris = redirectUris; + } + + public List getWebOrigins() { + return webOrigins; + } + + public void setWebOrigins(List webOrigins) { + this.webOrigins = webOrigins; + } + + public Boolean getBearerOnly() { + return bearerOnly; + } + + public void setBearerOnly(Boolean bearerOnly) { + this.bearerOnly = bearerOnly; + } + + public Boolean getStandardFlowEnabled() { + return standardFlowEnabled; + } + + public void setStandardFlowEnabled(Boolean standardFlowEnabled) { + this.standardFlowEnabled = standardFlowEnabled; + } + + public Boolean getImplicitFlowEnabled() { + return implicitFlowEnabled; + } + + public void setImplicitFlowEnabled(Boolean implicitFlowEnabled) { + this.implicitFlowEnabled = implicitFlowEnabled; + } + + public Boolean getDirectAccessGrantsEnabled() { + return directAccessGrantsEnabled; + } + + public void setDirectAccessGrantsEnabled(Boolean directAccessGrantsEnabled) { + this.directAccessGrantsEnabled = directAccessGrantsEnabled; + } + + public Boolean getServiceAccountsEnabled() { + return serviceAccountsEnabled; + } + + public void setServiceAccountsEnabled(Boolean serviceAccountsEnabled) { + this.serviceAccountsEnabled = serviceAccountsEnabled; + } + + public Boolean getPublicClient() { + return publicClient; + } + + public void setPublicClient(Boolean publicClient) { + this.publicClient = publicClient; + } + + public String getProtocol() { + return protocol; + } + + public void setProtocol(String protocol) { + this.protocol = protocol; + } + + public Map getAttributes() { + return attributes; + } + + public void setAttributes(Map attributes) { + this.attributes = attributes; + } + + public List getDefaultClientScopes() { + return defaultClientScopes; + } + + public void setDefaultClientScopes(List defaultClientScopes) { + this.defaultClientScopes = defaultClientScopes; + } + + public List getOptionalClientScopes() { + return optionalClientScopes; + } + + public void setOptionalClientScopes(List optionalClientScopes) { + this.optionalClientScopes = optionalClientScopes; + } +} diff --git a/src/main/java/org/tkit/onecx/iam/kc/client/operator/KeycloakClient.java b/src/main/java/org/tkit/onecx/iam/kc/client/operator/KeycloakClient.java new file mode 100644 index 0000000..90c8faa --- /dev/null +++ b/src/main/java/org/tkit/onecx/iam/kc/client/operator/KeycloakClient.java @@ -0,0 +1,10 @@ +package org.tkit.onecx.iam.kc.client.operator; + +import io.fabric8.kubernetes.api.model.Namespaced; +import io.fabric8.kubernetes.client.CustomResource; +import io.fabric8.kubernetes.model.annotation.*; + +@Version("v1") +@Group("onecx.tkit.org") +public class KeycloakClient extends CustomResource implements Namespaced { +} diff --git a/src/main/java/org/tkit/onecx/iam/kc/client/operator/KeycloakClientController.java b/src/main/java/org/tkit/onecx/iam/kc/client/operator/KeycloakClientController.java new file mode 100644 index 0000000..66867f2 --- /dev/null +++ b/src/main/java/org/tkit/onecx/iam/kc/client/operator/KeycloakClientController.java @@ -0,0 +1,181 @@ +package org.tkit.onecx.iam.kc.client.operator; + +import java.util.Base64; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; + +import jakarta.inject.Inject; +import jakarta.ws.rs.WebApplicationException; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.tkit.onecx.iam.kc.client.operator.service.KeycloakAdminService; +import org.tkit.onecx.iam.kc.client.operator.service.TypeNotSupportedException; + +import io.fabric8.kubernetes.api.model.Secret; +import io.javaoperatorsdk.operator.api.config.informer.InformerConfiguration; +import io.javaoperatorsdk.operator.api.reconciler.*; +import io.javaoperatorsdk.operator.processing.event.ResourceID; +import io.javaoperatorsdk.operator.processing.event.source.EventSource; +import io.javaoperatorsdk.operator.processing.event.source.SecondaryToPrimaryMapper; +import io.javaoperatorsdk.operator.processing.event.source.filter.OnAddFilter; +import io.javaoperatorsdk.operator.processing.event.source.filter.OnUpdateFilter; +import io.javaoperatorsdk.operator.processing.event.source.informer.InformerEventSource; + +@ControllerConfiguration(name = "kc", onAddFilter = KeycloakClientController.AddFilter.class, onUpdateFilter = KeycloakClientController.UpdateFilter.class) +public class KeycloakClientController + implements Reconciler, ErrorStatusHandler, Cleaner, + EventSourceInitializer { + + private static final Logger log = LoggerFactory.getLogger(KeycloakClientController.class); + + @Inject + KeycloakAdminService service; + + @Override + public ErrorStatusUpdateControl updateErrorStatus(KeycloakClient keycloakClient, + Context context, Exception e) { + int responseCode = -1; + String message = e.getMessage(); + if (e.getCause() instanceof WebApplicationException re) { + responseCode = re.getResponse().getStatus(); + message = re.getMessage(); + } + if (e.getCause() instanceof TypeNotSupportedException tnse) { + // set the response code as 500 INTERNAL Server error + responseCode = 500; + message = tnse.getMessage(); + } + if (e.getCause() instanceof MissingMandatoryKeyException mmke) { + // set the response code as 500 INTERNAL Server error + responseCode = 500; + message = mmke.getMessage(); + } + + log.error("Error reconcile resource", e); + var status = new KeycloakClientStatus(); + String clientId = null; + if (keycloakClient.getSpec().getKcConfig() != null) { + clientId = keycloakClient.getSpec().getKcConfig().getClientId() != null + ? keycloakClient.getSpec().getKcConfig().getClientId() + : null; + } + status.setClientId(clientId); + status.setResponseCode(responseCode); + status.setStatus(KeycloakClientStatus.Status.ERROR); + status.setMessage(message); + keycloakClient.setStatus(status); + return ErrorStatusUpdateControl.updateStatus(keycloakClient); + } + + @Override + public UpdateControl reconcile(KeycloakClient keycloakClient, Context context) + throws Exception { + log.info("Reconcile resource: {} appId: {}", keycloakClient.getMetadata().getName(), + keycloakClient.getSpec().getKcConfig().getClientId()); + + Optional secret = context.getSecondaryResource(Secret.class); + if (secret.isPresent()) { + + String name = keycloakClient.getMetadata().getName(); + String namespace = keycloakClient.getMetadata().getNamespace(); + + log.info("Reconcile password from secret for client: {} namespace: {}", name, namespace); + byte[] password = createRequestData(keycloakClient.getSpec(), secret.get()); + keycloakClient.getSpec().getKcConfig().setPassword(new String(password)); + } + + int responseCode = service.createClient(keycloakClient); + + updateStatusPojo(keycloakClient, responseCode); + log.info("Resource '{}' reconciled - updating status", keycloakClient.getMetadata().getName()); + return UpdateControl.updateStatus(keycloakClient); + } + + private void updateStatusPojo(KeycloakClient keycloakClient, int responseCode) { + KeycloakClientStatus result = new KeycloakClientStatus(); + KeycloakClientSpec spec = keycloakClient.getSpec(); + result.setClientId(spec.getKcConfig().getClientId()); + result.setResponseCode(responseCode); + var status = KeycloakClientStatus.Status.UNDEFINED; + if (responseCode == 200) { + status = KeycloakClientStatus.Status.UPDATED; + } + if (responseCode == 201) { + status = KeycloakClientStatus.Status.CREATED; + } + + result.setStatus(status); + keycloakClient.setStatus(result); + } + + @Override + public DeleteControl cleanup(KeycloakClient keycloakclient, Context context) { + service.deleteClient(keycloakclient); + return DeleteControl.defaultDelete(); + } + + @Override + public Map prepareEventSources(EventSourceContext context) { + final SecondaryToPrimaryMapper webappsMatchingTomcatName = (Secret t) -> context.getPrimaryCache() + .list(keycloakClient -> { + if (keycloakClient.getSpec() != null) { + return t.getMetadata().getName().equals(keycloakClient.getSpec().getPasswordSecrets()); + } + return false; + }) + .map(ResourceID::fromResource) + .collect(Collectors.toSet()); + + InformerConfiguration configuration = InformerConfiguration.from(Secret.class, context) + .withSecondaryToPrimaryMapper(webappsMatchingTomcatName) + .withPrimaryToSecondaryMapper( + (KeycloakClient primary) -> Set.of(new ResourceID(primary.getSpec().getPasswordSecrets(), + primary.getMetadata().getNamespace()))) + .build(); + return EventSourceInitializer + .nameEventSources(new InformerEventSource<>(configuration, context)); + } + + public static class AddFilter implements OnAddFilter { + + @Override + public boolean accept(KeycloakClient resource) { + return resource.getSpec() != null; + } + } + + public static class UpdateFilter implements OnUpdateFilter { + + @Override + public boolean accept(KeycloakClient newResource, KeycloakClient oldResource) { + return newResource.getSpec() != null; + } + } + + private static byte[] createRequestData(KeycloakClientSpec spec, Secret secret) throws MissingMandatoryKeyException { + Map data = secret.getData(); + + String key = spec.getPasswordKey(); + if (key == null) { + throw new MissingMandatoryKeyException("Secret key is mandatory. No key found!"); + } + if (!data.containsKey(key)) { + throw new MissingMandatoryKeyException("Secret key is mandatory. No key secret found!"); + } + String value = data.get(key); + if (value.isEmpty()) { + throw new MissingMandatoryKeyException("Secret key '" + key + "' is mandatory. No value found!"); + } + return Base64.getDecoder().decode(value); + } + + public static class MissingMandatoryKeyException extends RuntimeException { + + public MissingMandatoryKeyException(String msg) { + super(msg); + } + } +} diff --git a/src/main/java/org/tkit/onecx/iam/kc/client/operator/KeycloakClientSpec.java b/src/main/java/org/tkit/onecx/iam/kc/client/operator/KeycloakClientSpec.java new file mode 100644 index 0000000..4476523 --- /dev/null +++ b/src/main/java/org/tkit/onecx/iam/kc/client/operator/KeycloakClientSpec.java @@ -0,0 +1,60 @@ +package org.tkit.onecx.iam.kc.client.operator; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; + +@JsonInclude(JsonInclude.Include.NON_NULL) +public class KeycloakClientSpec { + + private String realm; + + @JsonProperty(required = true) + private String type; + + private String passwordSecrets; + + private String passwordKey; + + @JsonProperty(required = true) + private KCConfig kcConfig; + + public String getRealm() { + return realm; + } + + public String getType() { + return type; + } + + public void setType(String type) { + this.type = type; + } + + public KCConfig getKcConfig() { + return kcConfig; + } + + public void setKcConfig(KCConfig kcConfig) { + this.kcConfig = kcConfig; + } + + public void setRealm(String realm) { + this.realm = realm; + } + + public String getPasswordSecrets() { + return passwordSecrets; + } + + public void setPasswordSecrets(String passwordSecrets) { + this.passwordSecrets = passwordSecrets; + } + + public String getPasswordKey() { + return passwordKey; + } + + public void setPasswordKey(String passwordKey) { + this.passwordKey = passwordKey; + } +} diff --git a/src/main/java/org/tkit/onecx/iam/kc/client/operator/KeycloakClientStatus.java b/src/main/java/org/tkit/onecx/iam/kc/client/operator/KeycloakClientStatus.java new file mode 100644 index 0000000..670422d --- /dev/null +++ b/src/main/java/org/tkit/onecx/iam/kc/client/operator/KeycloakClientStatus.java @@ -0,0 +1,63 @@ +package org.tkit.onecx.iam.kc.client.operator; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import io.javaoperatorsdk.operator.api.ObservedGenerationAwareStatus; + +public class KeycloakClientStatus extends ObservedGenerationAwareStatus { + + @JsonProperty("clientId") + private String clientId; + + @JsonProperty("response-code") + private int responseCode; + + @JsonProperty("status") + private Status status; + + @JsonProperty("message") + private String message; + + public enum Status { + + ERROR, + + CREATED, + + UPDATED, + + UNDEFINED; + } + + public String getClientId() { + return clientId; + } + + public void setClientId(String clientId) { + this.clientId = clientId; + } + + public int getResponseCode() { + return responseCode; + } + + public void setResponseCode(int responseCode) { + this.responseCode = responseCode; + } + + public Status getStatus() { + return status; + } + + public void setStatus(Status status) { + this.status = status; + } + + public String getMessage() { + return message; + } + + public void setMessage(String message) { + this.message = message; + } +} diff --git a/src/main/java/org/tkit/onecx/iam/kc/client/operator/config/KCClientConfig.java b/src/main/java/org/tkit/onecx/iam/kc/client/operator/config/KCClientConfig.java new file mode 100644 index 0000000..313ec2e --- /dev/null +++ b/src/main/java/org/tkit/onecx/iam/kc/client/operator/config/KCClientConfig.java @@ -0,0 +1,28 @@ +package org.tkit.onecx.iam.kc.client.operator.config; + +import java.util.Map; + +import io.quarkus.runtime.annotations.ConfigPhase; +import io.quarkus.runtime.annotations.ConfigRoot; +import io.smallrye.config.ConfigMapping; +import io.smallrye.config.WithDefault; +import io.smallrye.config.WithName; + +@ConfigMapping(prefix = "onecx.iam.kc.client") +@ConfigRoot(phase = ConfigPhase.RUN_TIME) +public interface KCClientConfig { + + /** + * Define realm where to insert/update/delete the clients + */ + @WithName("realm") + @WithDefault("onecx") + String realm(); + + /** + * Default configuration for the (ui|machine) clients + */ + @WithName("config") + Map config(); + +} diff --git a/src/main/java/org/tkit/onecx/iam/kc/client/operator/config/KCDefaultConfig.java b/src/main/java/org/tkit/onecx/iam/kc/client/operator/config/KCDefaultConfig.java new file mode 100644 index 0000000..0455739 --- /dev/null +++ b/src/main/java/org/tkit/onecx/iam/kc/client/operator/config/KCDefaultConfig.java @@ -0,0 +1,111 @@ +package org.tkit.onecx.iam.kc.client.operator.config; + +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import io.smallrye.config.WithDefault; +import io.smallrye.config.WithName; + +public interface KCDefaultConfig { + + /** + * Add default scopes from realm to the client. + */ + @WithName("add-def-scopes") + @WithDefault(value = "true") + Boolean addDefaultScopes(); + + /** + * Enable the client + */ + @WithName("enabled") + @WithDefault(value = "true") + Boolean enabled(); + + /** + * Authentication type. + */ + @WithName("auth-type") + @WithDefault("client-secret") + String clientAuthenticatorType(); + + /** + * List of redirect uris. + */ + @WithName("redirect-uris") + Optional> redirectUris(); + + /** + * List of web origins + */ + @WithName("web-origins") + Optional> webOrigins(); + + /** + * Bearer token only. + */ + @WithName("bearer-only") + @WithDefault(value = "false") + Boolean bearerOnly(); + + /** + * Standard flow enabled. + */ + @WithName("standard-flow") + @WithDefault("false") + Boolean standardFlowEnabled(); + + /** + * Implicit flow enabled. + */ + @WithName("implicit-flow") + @WithDefault("false") + Boolean implicitFlowEnabled(); + + /** + * Enable direct access grants. + */ + @WithName("direct-access") + @WithDefault("false") + Boolean directAccessGrantsEnabled(); + + /** + * Enable service account. + */ + @WithName("service-account") + @WithDefault("true") + Boolean serviceAccountsEnabled(); + + /** + * Public client flag. + */ + @WithName("public") + @WithDefault("false") + Boolean publicClient(); + + /** + * Protocol used with the client. + */ + @WithName("protocol") + @WithDefault("openid-connect") + String protocol(); + + /** + * Attributes map for the client. + */ + @WithName("attributes") + Map attributes(); + + /** + * Default client scopes. + */ + @WithName("default-scopes") + Optional> defaultClientScopes(); + + /** + * Optional client scopes. + */ + @WithName("optional-scopes") + Optional> optionalClientScopes(); +} diff --git a/src/main/java/org/tkit/onecx/iam/kc/client/operator/service/KeycloakAdminService.java b/src/main/java/org/tkit/onecx/iam/kc/client/operator/service/KeycloakAdminService.java new file mode 100644 index 0000000..92a6be6 --- /dev/null +++ b/src/main/java/org/tkit/onecx/iam/kc/client/operator/service/KeycloakAdminService.java @@ -0,0 +1,254 @@ +package org.tkit.onecx.iam.kc.client.operator.service; + +import java.util.*; +import java.util.stream.Collectors; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.context.control.ActivateRequestContext; +import jakarta.inject.Inject; + +import org.keycloak.admin.client.Keycloak; +import org.keycloak.admin.client.resource.ClientResource; +import org.keycloak.representations.idm.ClientRepresentation; +import org.keycloak.representations.idm.ClientScopeRepresentation; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.tkit.onecx.iam.kc.client.operator.KCConfig; +import org.tkit.onecx.iam.kc.client.operator.KeycloakClient; +import org.tkit.onecx.iam.kc.client.operator.config.KCClientConfig; +import org.tkit.onecx.iam.kc.client.operator.config.KCDefaultConfig; +import org.tkit.quarkus.log.cdi.LogService; + +@LogService +@ApplicationScoped +public class KeycloakAdminService { + + private static final Logger log = LoggerFactory.getLogger(KeycloakAdminService.class); + + public static final String UI_TYPE = "ui"; + public static final String MACHINE_TYPE = "machine"; + + public static final String PROTOCOL_OPENID_CONNECT = "openid-connect"; + + @Inject + Keycloak keycloak; + + @Inject + KCClientConfig kcClientConfig; + + @ActivateRequestContext + public int createClient(KeycloakClient keycloakClient) { + var spec = keycloakClient.getSpec(); + var clientId = spec.getKcConfig().getClientId(); + var realm = spec.getRealm() != null ? spec.getRealm() : kcClientConfig.realm(); + ClientRepresentation client = null; + KCDefaultConfig clientDefaultConfig; + List clients = keycloak.realm(realm).clients().findByClientId(clientId); + client = clients.isEmpty() ? new ClientRepresentation() : clients.get(0); + + if (UI_TYPE.equalsIgnoreCase(spec.getType())) { + clientDefaultConfig = kcClientConfig.config().get(UI_TYPE.toLowerCase()); + prepareUpdateClient(client, spec.getKcConfig(), clientDefaultConfig); + } else if (MACHINE_TYPE.equalsIgnoreCase(spec.getType())) { + clientDefaultConfig = kcClientConfig.config().get(MACHINE_TYPE.toLowerCase()); + prepareUpdateClient(client, spec.getKcConfig(), clientDefaultConfig); + } else { + throw new TypeNotSupportedException(spec.getType()); + } + + // check scopes if they exist and create them if necessary + checkAndCreateScopes(client, realm); + + if (Boolean.TRUE.equals(clientDefaultConfig.addDefaultScopes())) { + var defaultScopesKC = keycloak.realm(realm).getDefaultDefaultClientScopes().stream() + .filter(csr -> csr.getProtocol().equals(PROTOCOL_OPENID_CONNECT)).map(ClientScopeRepresentation::getName) + .collect(Collectors.toSet()); + var optionalScopesKC = keycloak.realm(realm).getDefaultOptionalClientScopes().stream() + .filter(csr -> csr.getProtocol().equals(PROTOCOL_OPENID_CONNECT)).map(ClientScopeRepresentation::getName) + .collect(Collectors.toSet()); + + // add default/optional scopes from realm + client.getDefaultClientScopes().addAll(defaultScopesKC); + client.getOptionalClientScopes().addAll(optionalScopesKC); + } + + if (clients.isEmpty()) { + // do create + try (var resp = keycloak.realm(realm).clients().create(client)) { + return resp.getStatus(); + } + } else { + // do update + var defaultClientScopes = client.getDefaultClientScopes(); + var optionalClientScopes = client.getOptionalClientScopes(); + var clientToUpdate = keycloak.realm(realm).clients().get(clients.get(0).getId()); + clientToUpdate.update(client); + // update default client scopes + var toRemove = clientToUpdate.getDefaultClientScopes().stream() + .filter(rep -> !defaultClientScopes.contains(rep.getName())).map(ClientScopeRepresentation::getId) + .collect(Collectors.toSet()); + var toAdd = new ArrayList<>(client.getDefaultClientScopes()); + toAdd.removeAll(clientToUpdate.getDefaultClientScopes().stream().map(ClientScopeRepresentation::getName) + .collect(Collectors.toSet())); + toRemove.forEach(scope -> removeDefaultClientScope(clientToUpdate, scope)); + toAdd.forEach(scope -> addDefaultClientScope(clientToUpdate, scope)); + // update optional client scopes + var toRemoveOpt = clientToUpdate.getOptionalClientScopes().stream() + .filter(rep -> !optionalClientScopes.contains(rep.getName())).map(ClientScopeRepresentation::getId) + .collect(Collectors.toSet()); + var toAddOpt = new ArrayList<>(client.getOptionalClientScopes()); + toAddOpt.removeAll(clientToUpdate.getOptionalClientScopes().stream().map(ClientScopeRepresentation::getName) + .collect(Collectors.toSet())); + toRemoveOpt.forEach(scope -> removeOptClientScope(clientToUpdate, scope)); + toAddOpt.forEach(scope -> addOptClientScope(clientToUpdate, scope)); + + return 200; + } + } + + @ActivateRequestContext + public void deleteClient(KeycloakClient keycloakClient) { + var spec = keycloakClient.getSpec(); + var clientId = spec.getKcConfig().getClientId(); + var realm = spec.getRealm() != null ? spec.getRealm() : kcClientConfig.realm(); + + List clients = keycloak.realm(realm).clients().findByClientId(clientId); + if (!clients.isEmpty()) { + keycloak.realm(realm).clients().get(clients.get(0).getId()).remove(); + } + } + + void addDefaultClientScope(ClientResource cr, String clientScope) { + try { + cr.addDefaultClientScope(clientScope); + } catch (Exception e) { + log.error("Error adding default client scope " + clientScope, e); + } + } + + void removeDefaultClientScope(ClientResource cr, String clientScope) { + try { + cr.removeDefaultClientScope(clientScope); + } catch (Exception e) { + log.error("Error removing default client scope " + clientScope, e); + } + } + + void addOptClientScope(ClientResource cr, String clientScope) { + try { + cr.addOptionalClientScope(clientScope); + } catch (Exception e) { + log.error("Error adding optional client scope " + clientScope, e); + } + } + + void removeOptClientScope(ClientResource cr, String clientScope) { + try { + cr.removeOptionalClientScope(clientScope); + } catch (Exception e) { + log.error("Error removing optional client scope " + clientScope, e); + } + } + + private void checkAndCreateScopes(ClientRepresentation clientRepresentation, String realm) { + var kcScopes = keycloak.realm(realm).clientScopes().findAll(); + var scopeNames = kcScopes.stream().map(ClientScopeRepresentation::getName).collect(Collectors.toSet()); + + var scopesToAdd = new HashSet(); + + // collect default scopes + scopesToAdd.addAll(clientRepresentation.getDefaultClientScopes()); + + // collect optional scopes + scopesToAdd.addAll(clientRepresentation.getOptionalClientScopes()); + + // remove all known scopes + scopesToAdd.removeAll(scopeNames); + + // add all missing scopes to keycloak + if (!scopesToAdd.isEmpty()) { + scopesToAdd.forEach(scopeName -> createClientScope(scopeName, realm)); + } + } + + private void createClientScope(String clientScopeName, String realm) { + var clientScope = new ClientScopeRepresentation(); + clientScope.setId(clientScopeName); + clientScope.setName(clientScopeName); + clientScope.setProtocol(PROTOCOL_OPENID_CONNECT); + clientScope.setDescription("Generated scope " + clientScopeName + " by operator"); + + try (var resp = keycloak.realm(realm).clientScopes().create(clientScope)) { + log.info("Client scope {} creation ended with status {}", clientScopeName, resp.getStatus()); + } + + } + + private void prepareUpdateClient(ClientRepresentation clientRepresentation, KCConfig client, + KCDefaultConfig defaultConfig) { + clientRepresentation.setClientId(client.getClientId()); + clientRepresentation.setDescription(client.getDescription()); + + clientRepresentation.setEnabled(resolveValue(client.getEnabled(), defaultConfig.enabled())); + clientRepresentation.setClientAuthenticatorType( + resolveValue(client.getClientAuthenticatorType(), defaultConfig.clientAuthenticatorType())); + clientRepresentation.setSecret(client.getPassword()); + clientRepresentation.setRedirectUris( + resolveValue(client.getRedirectUris(), defaultConfig.redirectUris().orElse(new ArrayList<>()))); + clientRepresentation + .setWebOrigins(resolveValue(client.getWebOrigins(), defaultConfig.webOrigins().orElse(new ArrayList<>()))); + clientRepresentation.setBearerOnly(resolveValue(client.getBearerOnly(), defaultConfig.bearerOnly())); + clientRepresentation + .setStandardFlowEnabled(resolveValue(client.getStandardFlowEnabled(), defaultConfig.standardFlowEnabled())); + clientRepresentation + .setImplicitFlowEnabled(resolveValue(client.getImplicitFlowEnabled(), defaultConfig.implicitFlowEnabled())); + clientRepresentation.setDirectAccessGrantsEnabled( + resolveValue(client.getDirectAccessGrantsEnabled(), defaultConfig.directAccessGrantsEnabled())); + clientRepresentation.setServiceAccountsEnabled( + resolveValue(client.getServiceAccountsEnabled(), defaultConfig.serviceAccountsEnabled())); + clientRepresentation.setPublicClient(resolveValue(client.getPublicClient(), defaultConfig.publicClient())); + clientRepresentation.setProtocol(resolveValue(client.getProtocol(), defaultConfig.protocol())); + clientRepresentation.setAttributes(resolveValue(client.getAttributes(), defaultConfig.attributes())); + + clientRepresentation + .setDefaultClientScopes(resolveValue(client.getDefaultClientScopes(), + defaultConfig.defaultClientScopes().orElse(new ArrayList<>()))); + clientRepresentation.setOptionalClientScopes( + resolveValue(client.getOptionalClientScopes(), defaultConfig.optionalClientScopes().orElse(new ArrayList<>()))); + } + + private Map resolveValue(Map value, Map defaultValue) { + Map finalMap = new HashMap<>(defaultValue); + + if (value != null) { + finalMap.putAll(value); + } + + return finalMap; + } + + private List resolveValue(List value, List defaultValue) { + if (value != null) { + return new ArrayList<>(value); + } + + return new ArrayList<>(defaultValue); + } + + private Boolean resolveValue(Boolean value, Boolean defaultValue) { + if (value != null) { + return value; + } + + return defaultValue; + } + + private String resolveValue(String value, String defaultValue) { + if (value != null) { + return value; + } + + return defaultValue; + } + +} diff --git a/src/main/java/org/tkit/onecx/iam/kc/client/operator/service/TypeNotSupportedException.java b/src/main/java/org/tkit/onecx/iam/kc/client/operator/service/TypeNotSupportedException.java new file mode 100644 index 0000000..0b434dc --- /dev/null +++ b/src/main/java/org/tkit/onecx/iam/kc/client/operator/service/TypeNotSupportedException.java @@ -0,0 +1,12 @@ +package org.tkit.onecx.iam.kc.client.operator.service; + +import java.util.List; + +public class TypeNotSupportedException extends RuntimeException { + + public TypeNotSupportedException(String typeName) { + super("Client type " + typeName + " is not supported. Supported options only: " + + List.of(KeycloakAdminService.UI_TYPE, KeycloakAdminService.MACHINE_TYPE)); + } + +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties new file mode 100644 index 0000000..a32a3c3 --- /dev/null +++ b/src/main/resources/application.properties @@ -0,0 +1,57 @@ +quarkus.kubernetes-client.devservices.override-kubeconfig=true +quarkus.keycloak.admin-client.server-url=http://keycloak:8080 +quarkus.keycloak.admin-client.realm=master +quarkus.keycloak.admin-client.username=admin +quarkus.keycloak.admin-client.password=admin + +quarkus.operator-sdk.helm.enabled=true + +# Setup UI / Machine keycloak client defaults +onecx.iam.kc.client.realm=onecx + +onecx.iam.kc.client.config.ui.enabled=true +onecx.iam.kc.client.config.ui.auth-type=client-secret +onecx.iam.kc.client.config.ui.redirect-uris=* +onecx.iam.kc.client.config.ui.web-origins=* +onecx.iam.kc.client.config.ui.bearer-only=false +onecx.iam.kc.client.config.ui.standard-flow=true +onecx.iam.kc.client.config.ui.implicit-flow=false +onecx.iam.kc.client.config.ui.direct-access=true +onecx.iam.kc.client.config.ui.service-account=false +onecx.iam.kc.client.config.ui.protocol=openid-connect +onecx.iam.kc.client.config.ui.default-scopes=web-origins,roles,profile,email +onecx.iam.kc.client.config.ui.public=true +onecx.iam.kc.client.config.ui.add-def-scopes=true + +onecx.iam.kc.client.config.machine.enabled=true +onecx.iam.kc.client.config.machine.auth-type=client-secret +onecx.iam.kc.client.config.machine.bearer-only=false +onecx.iam.kc.client.config.machine.standard-flow=false +onecx.iam.kc.client.config.machine.implicit-flow=false +onecx.iam.kc.client.config.machine.direct-access=false +onecx.iam.kc.client.config.machine.service-account=true +onecx.iam.kc.client.config.machine.protocol=openid-connect +onecx.iam.kc.client.config.machine.default-scopes=web-origins,roles,profile,email +onecx.iam.kc.client.config.machine.public=false +onecx.iam.kc.client.config.machine.add-def-scopes=true + +# PROD + +# TEST +quarkus.test.integration-test-profile=test +%test.quarkus.keycloak.admin-client.server-url=${keycloak.url} +%test.quarkus.keycloak.devservices.show-logs=false +%test.quarkus.keycloak.devservices.roles.alice=onecx-portal-super-admin,onecx-portal-admin +%test.quarkus.keycloak.devservices.roles.bob=onecx-portal-user +%test.smallrye.jwt.verify.key.location=${keycloak.url}/realms/quarkus/protocol/openid-connect/certs +%test.onecx.iam.kc.client.realm=quarkus +%test.onecx.iam.kc.client.config.ui.add-def-scopes=true +%test.onecx.iam.kc.client.config.machine.add-def-scopes=false + +# DEV +%dev.quarkus.keycloak.admin-client.server-url=${keycloak.url} +%dev.quarkus.keycloak.devservices.show-logs=false +%dev.quarkus.keycloak.devservices.roles.alice=onecx-portal-super-admin,onecx-portal-admin +%dev.quarkus.keycloak.devservices.roles.bob=onecx-portal-user +%dev.smallrye.jwt.verify.key.location=${keycloak.url}/realms/quarkus/protocol/openid-connect/certs + diff --git a/src/test/java/org/tkit/onecx/iam/kc/client/operator/KeycloakClientControllerMockTest.java b/src/test/java/org/tkit/onecx/iam/kc/client/operator/KeycloakClientControllerMockTest.java new file mode 100644 index 0000000..31296c9 --- /dev/null +++ b/src/test/java/org/tkit/onecx/iam/kc/client/operator/KeycloakClientControllerMockTest.java @@ -0,0 +1,211 @@ +package org.tkit.onecx.iam.kc.client.operator; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; + +import java.util.List; +import java.util.Map; + +import jakarta.inject.Inject; + +import org.junit.jupiter.api.*; +import org.keycloak.admin.client.resource.*; +import org.keycloak.representations.adapters.action.GlobalRequestResult; +import org.keycloak.representations.idm.*; +import org.tkit.onecx.iam.kc.client.operator.service.KeycloakAdminService; +import org.tkit.onecx.iam.kc.client.test.AbstractTest; + +import io.quarkus.test.junit.QuarkusTest; + +@QuarkusTest +class KeycloakClientControllerMockTest extends AbstractTest { + + @Inject + KeycloakAdminService kas; + + @Test + void testThrowException() throws NoSuchMethodException { + + MockClientResource mockClientResource = new MockClientResource(); + + var addOptMethod = KeycloakAdminService.class.getDeclaredMethod("addOptClientScope", ClientResource.class, + String.class); + addOptMethod.setAccessible(true); + assertDoesNotThrow(() -> addOptMethod.invoke(kas, mockClientResource, "test")); + + var removeOptMethod = KeycloakAdminService.class.getDeclaredMethod("removeOptClientScope", ClientResource.class, + String.class); + removeOptMethod.setAccessible(true); + assertDoesNotThrow(() -> removeOptMethod.invoke(kas, mockClientResource, "test")); + + var addDefaultMethod = KeycloakAdminService.class.getDeclaredMethod("addDefaultClientScope", ClientResource.class, + String.class); + addDefaultMethod.setAccessible(true); + assertDoesNotThrow(() -> addDefaultMethod.invoke(kas, mockClientResource, "test")); + + var removeDefaultMethod = KeycloakAdminService.class.getDeclaredMethod("removeDefaultClientScope", ClientResource.class, + String.class); + removeDefaultMethod.setAccessible(true); + assertDoesNotThrow(() -> removeDefaultMethod.invoke(kas, mockClientResource, "test")); + + } + + public class MockClientResource implements ClientResource { + + @Override + public ManagementPermissionReference setPermissions( + ManagementPermissionRepresentation managementPermissionRepresentation) { + return null; + } + + @Override + public ManagementPermissionReference getPermissions() { + return null; + } + + @Override + public ProtocolMappersResource getProtocolMappers() { + return null; + } + + @Override + public ClientRepresentation toRepresentation() { + return null; + } + + @Override + public void update(ClientRepresentation clientRepresentation) { + + } + + @Override + public void remove() { + + } + + @Override + public CredentialRepresentation generateNewSecret() { + return null; + } + + @Override + public CredentialRepresentation getSecret() { + return null; + } + + @Override + public ClientRepresentation regenerateRegistrationAccessToken() { + return null; + } + + @Override + public ClientAttributeCertificateResource getCertficateResource(String s) { + return null; + } + + @Override + public String getInstallationProvider(String s) { + return null; + } + + @Override + public Map getApplicationSessionCount() { + return null; + } + + @Override + public List getUserSessions(Integer integer, Integer integer1) { + return null; + } + + @Override + public Map getOfflineSessionCount() { + return null; + } + + @Override + public List getOfflineUserSessions(Integer integer, Integer integer1) { + return null; + } + + @Override + public void pushRevocation() { + + } + + @Override + public RoleMappingResource getScopeMappings() { + return null; + } + + @Override + public RolesResource roles() { + return null; + } + + @Override + public List getDefaultClientScopes() { + return null; + } + + @Override + public void addDefaultClientScope(String s) { + throw new RuntimeException("error"); + } + + @Override + public void removeDefaultClientScope(String s) { + throw new RuntimeException("error"); + } + + @Override + public List getOptionalClientScopes() { + return null; + } + + @Override + public void addOptionalClientScope(String s) { + throw new RuntimeException("error"); + } + + @Override + public void removeOptionalClientScope(String s) { + throw new RuntimeException("error"); + } + + @Override + public UserRepresentation getServiceAccountUser() { + return null; + } + + @Override + public void registerNode(Map map) { + + } + + @Override + public void unregisterNode(String s) { + + } + + @Override + public GlobalRequestResult testNodesAvailable() { + return null; + } + + @Override + public AuthorizationResource authorization() { + return null; + } + + @Override + public CredentialRepresentation getClientRotatedSecret() { + return null; + } + + @Override + public void invalidateRotatedSecret() { + + } + } + +} diff --git a/src/test/java/org/tkit/onecx/iam/kc/client/operator/KeycloakClientControllerTest.java b/src/test/java/org/tkit/onecx/iam/kc/client/operator/KeycloakClientControllerTest.java new file mode 100644 index 0000000..1944194 --- /dev/null +++ b/src/test/java/org/tkit/onecx/iam/kc/client/operator/KeycloakClientControllerTest.java @@ -0,0 +1,771 @@ +package org.tkit.onecx.iam.kc.client.operator; + +import static java.util.concurrent.TimeUnit.SECONDS; +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +import java.util.Base64; +import java.util.List; +import java.util.Map; + +import jakarta.inject.Inject; + +import org.apache.groovy.util.Maps; +import org.awaitility.Awaitility; +import org.junit.jupiter.api.*; +import org.keycloak.admin.client.Keycloak; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.tkit.onecx.iam.kc.client.operator.service.KeycloakAdminService; +import org.tkit.onecx.iam.kc.client.test.AbstractTest; + +import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; +import io.fabric8.kubernetes.api.model.Secret; +import io.fabric8.kubernetes.client.KubernetesClient; +import io.javaoperatorsdk.operator.Operator; +import io.quarkus.test.junit.QuarkusTest; +import io.quarkus.test.keycloak.client.KeycloakTestClient; + +@QuarkusTest +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +class KeycloakClientControllerTest extends AbstractTest { + + final static Logger log = LoggerFactory.getLogger(KeycloakClientControllerTest.class); + + @Inject + Operator operator; + + @Inject + KubernetesClient client; + + private static final KeycloakTestClient keycloakClient = new KeycloakTestClient(); + + @Inject + Keycloak keycloak; + + @BeforeAll + public static void init() { + Awaitility.setDefaultPollDelay(2, SECONDS); + Awaitility.setDefaultPollInterval(2, SECONDS); + Awaitility.setDefaultTimeout(10, SECONDS); + } + + @Test + @Order(1) + void createUIClient() { + var CLIENT_ID = "test-ui-client"; + operator.start(); + + KeycloakClient data = new KeycloakClient(); + data.setMetadata(new ObjectMetaBuilder().withName(CLIENT_ID).withNamespace(client.getNamespace()).build()); + var kcClientSpec = new KeycloakClientSpec(); + kcClientSpec.setRealm(REALM_QUARKUS); + kcClientSpec.setType(KeycloakAdminService.UI_TYPE); + var kcConfig = new KCConfig(); + kcClientSpec.setKcConfig(kcConfig); + kcConfig.setClientId(CLIENT_ID); + kcConfig.setDescription(CLIENT_ID); + kcConfig.setDefaultClientScopes(List.of("create-scope-1", "create-scope-2")); + kcConfig.setOptionalClientScopes(List.of("opt-scope-1", "opt-scope-2")); + kcConfig.setAttributes(Maps.of("create.attr.1", "create.values.1", "create.attr.2", "create.values.2")); + data.setSpec(kcClientSpec); + + log.info("Creating test keycloak client object: {}", data); + client.resource(data).serverSideApply(); + + log.info("Waiting 4 seconds and status is CREATED"); + + await().pollDelay(4, SECONDS).untilAsserted(() -> { + KeycloakClientStatus mfeStatus = client.resource(data).get().getStatus(); + assertThat(mfeStatus).isNotNull(); + assertThat(mfeStatus.getStatus()).isNotNull().isEqualTo(KeycloakClientStatus.Status.CREATED); + }); + + var clients = keycloak.realm(REALM_QUARKUS).clients().findByClientId(CLIENT_ID); + assertThat(clients).isNotEmpty(); + var clientRep = clients.get(0); + assertThat(clientRep.getDescription()).isEqualTo(kcConfig.getDescription()); + // validate that attributes are all in + assertThat(clientRep.getAttributes()).containsAllEntriesOf(kcConfig.getAttributes()); + assertThat(clientRep.getOptionalClientScopes()).containsAll(kcConfig.getOptionalClientScopes()); + + var token = keycloakClient.getAccessToken(USER_ALICE, CLIENT_ID); + assertThat(token).isNotNull(); + + var jws = resolveToken(token); + assertThat((String) jws.getClaim(UI_TOKEN_CLIENT_CLAIM_NAME)).isEqualTo(CLIENT_ID); + var scopeString = (String) jws.getClaim(SCOPE_CLAIM_NAME); + var scopes = scopeString.split(" "); + // validate all scopes are in + assertThat(scopes).containsAll(kcConfig.getDefaultClientScopes()); + } + + @Test + @Order(3) + void createUIClientAllOptionsFilled() { + var CLIENT_ID = "test-ui-client-all-ops"; + operator.start(); + + KeycloakClient data = new KeycloakClient(); + data.setMetadata(new ObjectMetaBuilder().withName(CLIENT_ID).withNamespace(client.getNamespace()).build()); + var kcClientSpec = new KeycloakClientSpec(); + kcClientSpec.setRealm(REALM_QUARKUS); + kcClientSpec.setType(KeycloakAdminService.UI_TYPE); + var kcConfig = new KCConfig(); + kcClientSpec.setKcConfig(kcConfig); + kcConfig.setClientId(CLIENT_ID); + kcConfig.setDescription(CLIENT_ID); + kcConfig.setEnabled(true); + kcConfig.setClientAuthenticatorType("client-secret"); + kcConfig.setPassword(CLIENT_ID); + kcConfig.setRedirectUris(List.of("*", "localhost")); + kcConfig.setWebOrigins(List.of("*", "localhost")); + kcConfig.setBearerOnly(false); + kcConfig.setStandardFlowEnabled(true); + kcConfig.setImplicitFlowEnabled(false); + kcConfig.setDirectAccessGrantsEnabled(true); + kcConfig.setServiceAccountsEnabled(false); + kcConfig.setPublicClient(true); + kcConfig.setProtocol(KeycloakAdminService.PROTOCOL_OPENID_CONNECT); + kcConfig.setDefaultClientScopes(List.of("create-scope-1", "create-scope-2")); + kcConfig.setOptionalClientScopes(List.of("opt-scope-1", "opt-scope-2")); + kcConfig.setAttributes(Maps.of("create.attr.1", "create.values.1", "create.attr.2", "create.values.2")); + data.setSpec(kcClientSpec); + + log.info("Creating test keycloak client object: {}", data); + client.resource(data).serverSideApply(); + + log.info("Waiting 4 seconds and status is CREATED"); + + await().pollDelay(4, SECONDS).untilAsserted(() -> { + KeycloakClientStatus mfeStatus = client.resource(data).get().getStatus(); + assertThat(mfeStatus).isNotNull(); + assertThat(mfeStatus.getStatus()).isNotNull().isEqualTo(KeycloakClientStatus.Status.CREATED); + }); + + var clients = keycloak.realm(REALM_QUARKUS).clients().findByClientId(CLIENT_ID); + assertThat(clients).isNotEmpty(); + var clientRep = clients.get(0); + assertThat(clientRep.getDescription()).isEqualTo(kcConfig.getDescription()); + // validate that attributes are all in + assertThat(clientRep.getAttributes()).containsAllEntriesOf(kcConfig.getAttributes()); + assertThat(clientRep.getOptionalClientScopes()).containsAll(kcConfig.getOptionalClientScopes()); + + var token = keycloakClient.getAccessToken(USER_ALICE, CLIENT_ID); + assertThat(token).isNotNull(); + + var jws = resolveToken(token); + assertThat((String) jws.getClaim(UI_TOKEN_CLIENT_CLAIM_NAME)).isEqualTo(CLIENT_ID); + var scopeString = (String) jws.getClaim(SCOPE_CLAIM_NAME); + var scopes = scopeString.split(" "); + // validate all scopes are in + assertThat(scopes).containsAll(kcConfig.getDefaultClientScopes()); + } + + @Test + @Order(2) + void updateUIClient() { + var CLIENT_ID = "test-ui-client"; + operator.start(); + + KeycloakClient data = new KeycloakClient(); + data.setMetadata(new ObjectMetaBuilder().withName(CLIENT_ID).withNamespace(client.getNamespace()).build()); + var kcClientSpec = new KeycloakClientSpec(); + kcClientSpec.setRealm(REALM_QUARKUS); + kcClientSpec.setType(KeycloakAdminService.UI_TYPE); + var kcConfig = new KCConfig(); + kcClientSpec.setKcConfig(kcConfig); + kcConfig.setClientId(CLIENT_ID); + kcConfig.setDescription("UPDATED-" + CLIENT_ID); + kcConfig.setDefaultClientScopes(List.of("test-scope-1", "test-scope-2")); + kcConfig.setOptionalClientScopes(List.of("opt-scope-1", "opt-scope-2-updated")); + kcConfig.setAttributes(Maps.of("create.attr.1", "udpate.values.1", "update.attr.2", "update.values.2")); + data.setSpec(kcClientSpec); + + log.info("Update test keycloak client object: {}", data); + client.resource(data).serverSideApply(); + + log.info("Waiting 4 seconds and status is UPDATED"); + + await().pollDelay(4, SECONDS).untilAsserted(() -> { + KeycloakClientStatus mfeStatus = client.resource(data).get().getStatus(); + assertThat(mfeStatus).isNotNull(); + assertThat(mfeStatus.getStatus()).isNotNull().isEqualTo(KeycloakClientStatus.Status.UPDATED); + }); + + var clients = keycloak.realm(REALM_QUARKUS).clients().findByClientId(CLIENT_ID); + assertThat(clients).isNotEmpty(); + var clientRep = clients.get(0); + assertThat(clientRep.getDescription()).isEqualTo(kcConfig.getDescription()); + // validate that attributes are all in + assertThat(clientRep.getAttributes()).containsAllEntriesOf(kcConfig.getAttributes()); + assertThat(clientRep.getOptionalClientScopes()).containsAll(kcConfig.getOptionalClientScopes()); + + var token = keycloakClient.getAccessToken(USER_ALICE, CLIENT_ID); + assertThat(token).isNotNull(); + + var jws = resolveToken(token); + assertThat((String) jws.getClaim(UI_TOKEN_CLIENT_CLAIM_NAME)).isEqualTo(CLIENT_ID); + var scopeString = (String) jws.getClaim(SCOPE_CLAIM_NAME); + var scopes = scopeString.split(" "); + // validate all scopes are in + assertThat(scopes).containsAll(kcConfig.getDefaultClientScopes()); + } + + @Test + @Order(4) + void createUIClientMinimumOption() { + var CLIENT_ID = "test-ui-client-min-ops"; + operator.start(); + + KeycloakClient data = new KeycloakClient(); + data.setMetadata(new ObjectMetaBuilder().withName(CLIENT_ID).withNamespace(client.getNamespace()).build()); + var kcClientSpec = new KeycloakClientSpec(); + kcClientSpec.setType(KeycloakAdminService.UI_TYPE); + var kcConfig = new KCConfig(); + kcClientSpec.setKcConfig(kcConfig); + kcConfig.setClientId(CLIENT_ID); + data.setSpec(kcClientSpec); + + log.info("Creating test keycloak client object: {}", data); + client.resource(data).serverSideApply(); + + log.info("Waiting 4 seconds and status is CREATED"); + + await().pollDelay(4, SECONDS).untilAsserted(() -> { + KeycloakClientStatus mfeStatus = client.resource(data).get().getStatus(); + assertThat(mfeStatus).isNotNull(); + assertThat(mfeStatus.getStatus()).isNotNull().isEqualTo(KeycloakClientStatus.Status.CREATED); + }); + + var clients = keycloak.realm(REALM_QUARKUS).clients().findByClientId(CLIENT_ID); + assertThat(clients).isNotEmpty(); + + var token = keycloakClient.getRealmAccessToken(REALM_QUARKUS, USER_ALICE, CLIENT_ID); + assertThat(token).isNotNull(); + + var jws = resolveToken(token); + assertThat((String) jws.getClaim(UI_TOKEN_CLIENT_CLAIM_NAME)).isEqualTo(CLIENT_ID); + + log.info("Deleting test keycloak client object: {}", data); + var statusDetails = client.resource(data).delete(); + + log.info("Waiting 4 seconds and status is CREATED"); + + await().pollDelay(4, SECONDS).untilAsserted(() -> { + var mfeStatus = client.resource(data).get(); + assertThat(mfeStatus).isNull(); + }); + } + + @Test + @Order(5) + void deleteUIClientMinimumOption() { + var CLIENT_ID = "test-ui-client-min-ops-for-del"; + operator.start(); + + KeycloakClient data = new KeycloakClient(); + data.setMetadata(new ObjectMetaBuilder().withName(CLIENT_ID).withNamespace(client.getNamespace()).build()); + var kcClientSpec = new KeycloakClientSpec(); + kcClientSpec.setType(KeycloakAdminService.UI_TYPE); + var kcConfig = new KCConfig(); + kcClientSpec.setKcConfig(kcConfig); + kcConfig.setClientId(CLIENT_ID); + data.setSpec(kcClientSpec); + + log.info("Creating test keycloak client object: {}", data); + client.resource(data).serverSideApply(); + + log.info("Waiting 4 seconds and status is CREATED"); + + await().pollDelay(4, SECONDS).untilAsserted(() -> { + KeycloakClientStatus mfeStatus = client.resource(data).get().getStatus(); + assertThat(mfeStatus).isNotNull(); + assertThat(mfeStatus.getStatus()).isNotNull().isEqualTo(KeycloakClientStatus.Status.CREATED); + }); + + var foundClients = keycloak.realm(REALM_QUARKUS).clients().findByClientId(CLIENT_ID); + assertThat(foundClients).isNotEmpty(); + + log.info("Deleting test keycloak client object: {}", data); + client.resource(data).delete(); + + log.info("Waiting 4 seconds and status is CREATED"); + + await().pollDelay(4, SECONDS).untilAsserted(() -> { + var clientResource = client.resource(data).get(); + assertThat(clientResource).isNull(); + }); + + foundClients = keycloak.realm(REALM_QUARKUS).clients().findByClientId(CLIENT_ID); + assertThat(foundClients).isEmpty(); + } + + @Test + @Order(6) + void deleteAlreadyDeletedUIClient() { + var CLIENT_ID = "test-ui-client-min-ops-for-del"; + operator.start(); + + KeycloakClient data = new KeycloakClient(); + data.setMetadata(new ObjectMetaBuilder().withName(CLIENT_ID).withNamespace(client.getNamespace()).build()); + var kcClientSpec = new KeycloakClientSpec(); + kcClientSpec.setType(KeycloakAdminService.UI_TYPE); + kcClientSpec.setRealm("quarkus"); + var kcConfig = new KCConfig(); + kcClientSpec.setKcConfig(kcConfig); + kcConfig.setClientId(CLIENT_ID); + data.setSpec(kcClientSpec); + + log.info("Creating test keycloak client object: {}", data); + client.resource(data).serverSideApply(); + + log.info("Waiting 4 seconds and status is CREATED"); + + await().pollDelay(4, SECONDS).untilAsserted(() -> { + KeycloakClientStatus mfeStatus = client.resource(data).get().getStatus(); + assertThat(mfeStatus).isNotNull(); + assertThat(mfeStatus.getStatus()).isNotNull().isEqualTo(KeycloakClientStatus.Status.CREATED); + }); + + var foundClients = keycloak.realm(REALM_QUARKUS).clients().findByClientId(CLIENT_ID); + assertThat(foundClients).isNotEmpty(); + + keycloak.realm(REALM_QUARKUS).clients().get(foundClients.get(0).getId()).remove(); + + log.info("Deleting test keycloak client object: {}", data); + client.resource(data).delete(); + + log.info("Waiting 4 seconds and status is CREATED"); + + await().pollDelay(4, SECONDS).untilAsserted(() -> { + var clientResource = client.resource(data).get(); + assertThat(clientResource).isNull(); + }); + + foundClients = keycloak.realm(REALM_QUARKUS).clients().findByClientId(CLIENT_ID); + assertThat(foundClients).isEmpty(); + } + + @Test + @Order(10) + void createMachineClient() { + var CLIENT_ID = "test-client"; + var CLIENT_SECRET = "test-client-secret"; + operator.start(); + + KeycloakClient data = new KeycloakClient(); + data.setMetadata(new ObjectMetaBuilder().withName(CLIENT_ID).withNamespace(client.getNamespace()).build()); + var kcClientSpec = new KeycloakClientSpec(); + kcClientSpec.setRealm(REALM_QUARKUS); + kcClientSpec.setType(KeycloakAdminService.MACHINE_TYPE); + var kcConfig = new KCConfig(); + kcClientSpec.setKcConfig(kcConfig); + kcConfig.setClientId(CLIENT_ID); + kcConfig.setPassword(CLIENT_SECRET); + kcConfig.setDefaultClientScopes(List.of("create-scope-1", "create-scope-2")); + kcConfig.setAttributes(Maps.of("create.attr.1", "create.values.1", "create.attr.2", "create.values.2")); + data.setSpec(kcClientSpec); + + log.info("Creating test keycloak client object: {}", data); + client.resource(data).serverSideApply(); + + log.info("Waiting 4 seconds and status is CREATED"); + + await().pollDelay(4, SECONDS).untilAsserted(() -> { + KeycloakClientStatus mfeStatus = client.resource(data).get().getStatus(); + assertThat(mfeStatus).isNotNull(); + assertThat(mfeStatus.getStatus()).isNotNull().isEqualTo(KeycloakClientStatus.Status.CREATED); + }); + + var clients = keycloak.realm(REALM_QUARKUS).clients().findByClientId(CLIENT_ID); + assertThat(clients).isNotEmpty(); + var clientRep = clients.get(0); + assertThat(clientRep.getDescription()).isEqualTo(kcConfig.getDescription()); + // validate that attributes are all in + assertThat(clientRep.getAttributes()).containsAllEntriesOf(kcConfig.getAttributes()); + + var token = keycloakClient.getClientAccessToken(CLIENT_ID, CLIENT_SECRET); + assertThat(token).isNotNull(); + + var jws = resolveToken(token); + assertThat((String) jws.getClaim(UI_TOKEN_CLIENT_CLAIM_NAME)).isEqualTo(CLIENT_ID); + var scopeString = (String) jws.getClaim(SCOPE_CLAIM_NAME); + var scopes = scopeString.split(" "); + // validate all scopes are in + assertThat(scopes).containsAll(kcConfig.getDefaultClientScopes()); + } + + @Test + @Order(11) + void updateMachineClient() { + var CLIENT_ID = "test-client"; + var CLIENT_SECRET = "test-client-secret"; + operator.start(); + + KeycloakClient data = new KeycloakClient(); + data.setMetadata(new ObjectMetaBuilder().withName(CLIENT_ID).withNamespace(client.getNamespace()).build()); + var kcClientSpec = new KeycloakClientSpec(); + kcClientSpec.setRealm(REALM_QUARKUS); + kcClientSpec.setType(KeycloakAdminService.MACHINE_TYPE); + var kcConfig = new KCConfig(); + kcClientSpec.setKcConfig(kcConfig); + kcConfig.setClientId(CLIENT_ID); + kcConfig.setPassword(CLIENT_SECRET); + kcConfig.setDefaultClientScopes(List.of("create-scope-1", "update-scope-2")); + kcConfig.setAttributes(Maps.of("create.attr.1", "create.values.1.update", "update.attr.2", "update.values.2")); + data.setSpec(kcClientSpec); + + log.info("Updating test keycloak client object: {}", data); + client.resource(data).serverSideApply(); + + log.info("Waiting 4 seconds and status is UPDATED"); + + await().pollDelay(4, SECONDS).untilAsserted(() -> { + KeycloakClientStatus mfeStatus = client.resource(data).get().getStatus(); + assertThat(mfeStatus).isNotNull(); + assertThat(mfeStatus.getStatus()).isNotNull().isEqualTo(KeycloakClientStatus.Status.UPDATED); + }); + + var clients = keycloak.realm(REALM_QUARKUS).clients().findByClientId(CLIENT_ID); + assertThat(clients).isNotEmpty(); + var clientRep = clients.get(0); + assertThat(clientRep.getDescription()).isEqualTo(kcConfig.getDescription()); + // validate that attributes are all in + assertThat(clientRep.getAttributes()).containsAllEntriesOf(kcConfig.getAttributes()); + + var token = keycloakClient.getClientAccessToken(CLIENT_ID, CLIENT_SECRET); + assertThat(token).isNotNull(); + + var jws = resolveToken(token); + assertThat((String) jws.getClaim(UI_TOKEN_CLIENT_CLAIM_NAME)).isEqualTo(CLIENT_ID); + var scopeString = (String) jws.getClaim(SCOPE_CLAIM_NAME); + var scopes = scopeString.split(" "); + // validate all scopes are in + assertThat(scopes).containsAll(kcConfig.getDefaultClientScopes()); + + // update machine client with empty spec + data.setSpec(null); + log.info("Updating test keycloak client object: {}", data); + client.resource(data).serverSideApply(); + + await().pollDelay(4, SECONDS).untilAsserted(() -> { + KeycloakClientStatus mfeStatus = client.resource(data).get().getStatus(); + assertThat(mfeStatus).isNotNull(); + assertThat(mfeStatus.getStatus()).isNotNull().isEqualTo(KeycloakClientStatus.Status.UPDATED); + }); + } + + @Test + void updateMachinePwdClient() { + var CLIENT_ID = "test-client-pwd-chg"; + var CLIENT_SECRET = "test-client-secret"; + operator.start(); + + KeycloakClient data = new KeycloakClient(); + data.setMetadata(new ObjectMetaBuilder().withName(CLIENT_ID).withNamespace(client.getNamespace()).build()); + var kcClientSpec = new KeycloakClientSpec(); + kcClientSpec.setRealm(REALM_QUARKUS); + kcClientSpec.setType(KeycloakAdminService.MACHINE_TYPE); + var kcConfig = new KCConfig(); + kcClientSpec.setKcConfig(kcConfig); + kcConfig.setClientId(CLIENT_ID); + kcConfig.setPassword(CLIENT_SECRET); + kcConfig.setDefaultClientScopes(List.of("create-scope-1", "update-scope-2")); + kcConfig.setAttributes(Maps.of("create.attr.1", "create.values.1.update", "update.attr.2", "update.values.2")); + data.setSpec(kcClientSpec); + + log.info("Creating test keycloak client object: {}", data); + client.resource(data).serverSideApply(); + + log.info("Waiting 4 seconds and status is UPDATED"); + + await().pollDelay(4, SECONDS).untilAsserted(() -> { + KeycloakClientStatus mfeStatus = client.resource(data).get().getStatus(); + assertThat(mfeStatus).isNotNull(); + assertThat(mfeStatus.getStatus()).isNotNull().isEqualTo(KeycloakClientStatus.Status.CREATED); + }); + + var secret = keycloak.realm(REALM_QUARKUS).clients().findByClientId(CLIENT_ID).get(0).getSecret(); + log.info("Old secret {}", secret); + + // update the password + var NEW_CLIENT_PASSWORD = "test-client-secret-new"; + data.getSpec().getKcConfig().setPassword(NEW_CLIENT_PASSWORD); + + log.info("Updating test keycloak client with new password object: {}", data); + client.resource(data).update(); + + log.info("Waiting 4 seconds and status is UPDATED"); + + await().pollDelay(4, SECONDS).untilAsserted(() -> { + KeycloakClientStatus mfeStatus = client.resource(data).get().getStatus(); + assertThat(mfeStatus).isNotNull(); + assertThat(mfeStatus.getStatus()).isNotNull().isEqualTo(KeycloakClientStatus.Status.UPDATED); + }); + + secret = keycloak.realm(REALM_QUARKUS).clients().findByClientId(CLIENT_ID).get(0).getSecret(); + log.info("New secret {}", secret); + + var tokenWithOldPwd = keycloakClient.getClientAccessToken(CLIENT_ID, CLIENT_SECRET); + var tokenWithNewPwd = keycloakClient.getClientAccessToken(CLIENT_ID, NEW_CLIENT_PASSWORD); + + assertThat(tokenWithOldPwd).isNull(); + assertThat(tokenWithNewPwd).isNotNull(); + } + + @Test + void createUpdatePasswordFromSecretTest() { + Base64.Encoder encoder = Base64.getEncoder(); + + var CLIENT_ID = "test-machine-secret-client"; + var CLIENT_SECRET = "test-client-secret"; + var CLIENT_PWD_SECRET = "test-machine-secret-client-secret"; + var CLIENT_PWD_KEY = "pwd"; + operator.start(); + + KeycloakClient data = new KeycloakClient(); + data.setMetadata(new ObjectMetaBuilder().withName(CLIENT_ID).withNamespace(client.getNamespace()).build()); + var kcClientSpec = new KeycloakClientSpec(); + kcClientSpec.setRealm(REALM_QUARKUS); + kcClientSpec.setPasswordKey(CLIENT_PWD_KEY); + kcClientSpec.setPasswordSecrets(CLIENT_PWD_SECRET); + kcClientSpec.setType(KeycloakAdminService.MACHINE_TYPE); + var kcConfig = new KCConfig(); + kcClientSpec.setKcConfig(kcConfig); + kcConfig.setClientId(CLIENT_ID); + kcConfig.setPassword("someRandomPwdShouldBeIgnored"); + kcConfig.setDefaultClientScopes(List.of("create-scope-1", "create-scope-2")); + kcConfig.setAttributes(Maps.of("create.attr.1", "create.values.1", "create.attr.2", "create.values.2")); + data.setSpec(kcClientSpec); + + Secret secret = new Secret(); + secret.setMetadata(new ObjectMetaBuilder().withName(kcClientSpec.getPasswordSecrets()) + .withNamespace(client.getNamespace()).build()); + secret.setData(Map.of(kcClientSpec.getPasswordKey(), encoder.encodeToString(CLIENT_SECRET.getBytes()))); + + log.info("Creating secret object: {}", secret); + client.resource(secret).serverSideApply(); + + log.info("Creating keycloak client object {}", data); + client.resource(data).serverSideApply(); + + log.info("Waiting 4 seconds and status is CREATED"); + + await().pollDelay(4, SECONDS).untilAsserted(() -> { + KeycloakClientStatus mfeStatus = client.resource(data).get().getStatus(); + assertThat(mfeStatus).isNotNull(); + assertThat(mfeStatus.getStatus()).isNotNull().isEqualTo(KeycloakClientStatus.Status.CREATED); + }); + + var token = keycloakClient.getClientAccessToken(CLIENT_ID, CLIENT_SECRET); + assertThat(token).isNotNull(); + + // update the password + var CLIENT_SECRET_NEW = "new-machine-client-secret"; + secret.setData(Map.of(kcClientSpec.getPasswordKey(), encoder.encodeToString(CLIENT_SECRET_NEW.getBytes()))); + log.info("Updating secret object: {}", secret); + client.resource(secret).update(); + + log.info("Waiting 6 seconds and status is UPDATED"); + + await().pollDelay(6, SECONDS).untilAsserted(() -> { + KeycloakClientStatus mfeStatus = client.resource(data).get().getStatus(); + assertThat(mfeStatus).isNotNull(); + assertThat(mfeStatus.getStatus()).isNotNull().isEqualTo(KeycloakClientStatus.Status.UPDATED); + }); + // old password token empty + var oldSecretToken = keycloakClient.getClientAccessToken(CLIENT_ID, CLIENT_SECRET); + assertThat(oldSecretToken).isNull(); + + // new password generates token + var newSecretToken = keycloakClient.getClientAccessToken(CLIENT_ID, CLIENT_SECRET_NEW); + assertThat(newSecretToken).isNotNull(); + } + + @Test + void clientErrorTest() { + operator.start(); + + // Null specification + KeycloakClient data = new KeycloakClient(); + data.setMetadata(new ObjectMetaBuilder().withName("null-spec").withNamespace(client.getNamespace()).build()); + data.setSpec(null); + + log.info("Creating test keycloak client object: {}", data); + client.resource(data).serverSideApply(); + + log.info("Waiting 4 seconds and status is still null"); + + KeycloakClient finalData = data; + await().pollDelay(4, SECONDS).untilAsserted(() -> { + KeycloakClientStatus mfeStatus = client.resource(finalData).get().getStatus(); + assertThat(mfeStatus).isNull(); + }); + + // empty specification + data = new KeycloakClient(); + data.setMetadata(new ObjectMetaBuilder().withName("empty-spec").withNamespace(client.getNamespace()).build()); + data.setSpec(new KeycloakClientSpec()); + + log.info("Creating test keycloak client object: {}", data); + client.resource(data).serverSideApply(); + + log.info("Waiting 4 seconds and status has an ERROR"); + + KeycloakClient finalData2 = data; + await().pollDelay(4, SECONDS).untilAsserted(() -> { + KeycloakClientStatus mfeStatus = client.resource(finalData2).get().getStatus(); + assertThat(mfeStatus).isNotNull(); + assertThat(mfeStatus.getStatus()).isNotNull().isEqualTo(KeycloakClientStatus.Status.ERROR); + }); + + // empty config + data = new KeycloakClient(); + data.setMetadata(new ObjectMetaBuilder().withName("empty-config").withNamespace(client.getNamespace()).build()); + data.setSpec(new KeycloakClientSpec()); + data.getSpec().setKcConfig(new KCConfig()); + + log.info("Creating test keycloak client object: {}", data); + client.resource(data).serverSideApply(); + + log.info("Waiting 4 seconds and status has an ERROR"); + + KeycloakClient finalData3 = data; + await().pollDelay(4, SECONDS).untilAsserted(() -> { + KeycloakClientStatus mfeStatus = client.resource(finalData3).get().getStatus(); + assertThat(mfeStatus).isNotNull(); + assertThat(mfeStatus.getStatus()).isNotNull().isEqualTo(KeycloakClientStatus.Status.ERROR); + }); + } + + @Test + void clientNotExistingRealmTest() { + var CLIENT_ID = "wrong-type"; + operator.start(); + + KeycloakClient data = new KeycloakClient(); + data.setMetadata(new ObjectMetaBuilder().withName(CLIENT_ID).withNamespace(client.getNamespace()).build()); + data.setSpec(new KeycloakClientSpec()); + data.getSpec().setType(KeycloakAdminService.MACHINE_TYPE); + data.getSpec().setRealm("NOT_EXISTING"); + data.getSpec().setKcConfig(new KCConfig()); + data.getSpec().getKcConfig().setClientId(CLIENT_ID); + + log.info("Creating test keycloak client object: {}", data); + client.resource(data).serverSideApply(); + + log.info("Waiting 4 seconds and status has an ERROR"); + + await().pollDelay(4, SECONDS).untilAsserted(() -> { + KeycloakClientStatus mfeStatus = client.resource(data).get().getStatus(); + assertThat(mfeStatus).isNotNull(); + assertThat(mfeStatus.getStatus()).isNotNull().isEqualTo(KeycloakClientStatus.Status.ERROR); + }); + } + + @Test + void clientWrongTypeTest() { + var CLIENT_ID = "wrong-type"; + operator.start(); + + KeycloakClient data = new KeycloakClient(); + data.setMetadata(new ObjectMetaBuilder().withName(CLIENT_ID).withNamespace(client.getNamespace()).build()); + data.setSpec(new KeycloakClientSpec()); + data.getSpec().setType("CUSTOM_TYPE"); + data.getSpec().setKcConfig(new KCConfig()); + data.getSpec().getKcConfig().setClientId(CLIENT_ID); + + log.info("Creating test keycloak client object: {}", data); + client.resource(data).serverSideApply(); + + log.info("Waiting 4 seconds and status has an ERROR"); + + await().pollDelay(4, SECONDS).untilAsserted(() -> { + KeycloakClientStatus mfeStatus = client.resource(data).get().getStatus(); + assertThat(mfeStatus).isNotNull(); + assertThat(mfeStatus.getStatus()).isNotNull().isEqualTo(KeycloakClientStatus.Status.ERROR); + }); + } + + @Test + void createUpdatePasswordFromSecretErrorTest() { + Base64.Encoder encoder = Base64.getEncoder(); + + var CLIENT_ID = "test-machine-secret-client-err1"; + var CLIENT_SECRET = "test-client-secret"; + var CLIENT_PWD_SECRET = "err-machine-secret-client-secret"; + var CLIENT_PWD_KEY = "pwd"; + operator.start(); + + KeycloakClient data = new KeycloakClient(); + data.setMetadata(new ObjectMetaBuilder().withName(CLIENT_ID).withNamespace(client.getNamespace()).build()); + var kcClientSpec = new KeycloakClientSpec(); + kcClientSpec.setRealm(REALM_QUARKUS); + kcClientSpec.setPasswordSecrets(CLIENT_PWD_SECRET); + kcClientSpec.setType(KeycloakAdminService.MACHINE_TYPE); + var kcConfig = new KCConfig(); + kcClientSpec.setKcConfig(kcConfig); + kcConfig.setClientId(CLIENT_ID); + kcConfig.setPassword("someRandomPwdShouldBeIgnored"); + kcConfig.setDefaultClientScopes(List.of("create-scope-1", "create-scope-2")); + kcConfig.setAttributes(Maps.of("create.attr.1", "create.values.1", "create.attr.2", "create.values.2")); + data.setSpec(kcClientSpec); + + Secret secret = new Secret(); + secret.setMetadata(new ObjectMetaBuilder().withName(kcClientSpec.getPasswordSecrets()) + .withNamespace(client.getNamespace()).build()); + secret.setData(Map.of("other-key", encoder.encodeToString(CLIENT_SECRET.getBytes()))); + + log.info("Creating secret object: {}", secret); + client.resource(secret).serverSideApply(); + + // test when the client does container pwd secret name but not the pwd key + log.info("Creating keycloak client object {}", data); + client.resource(data).serverSideApply(); + + log.info("Waiting 4 seconds and status is ERROR"); + + await().pollDelay(4, SECONDS).untilAsserted(() -> { + KeycloakClientStatus mfeStatus = client.resource(data).get().getStatus(); + assertThat(mfeStatus).isNotNull(); + assertThat(mfeStatus.getResponseCode()).isEqualTo(500); + assertThat(mfeStatus.getMessage()).isEqualTo("Secret key is mandatory. No key found!"); + assertThat(mfeStatus.getStatus()).isNotNull().isEqualTo(KeycloakClientStatus.Status.ERROR); + }); + + // test error when secret does not contain the right key + KeycloakClient data1 = new KeycloakClient(); + data1.setMetadata(new ObjectMetaBuilder().withName("test-machine-secret-client-err2") + .withNamespace(client.getNamespace()).build()); + data1.setSpec(kcClientSpec); + kcClientSpec.setPasswordKey(CLIENT_PWD_KEY); + + log.info("Creating keycloak client object {}", data1); + client.resource(data1).serverSideApply(); + + log.info("Waiting 4 seconds and status is ERROR"); + + await().pollDelay(4, SECONDS).untilAsserted(() -> { + KeycloakClientStatus mfeStatus = client.resource(data1).get().getStatus(); + assertThat(mfeStatus).isNotNull(); + assertThat(mfeStatus.getMessage()).isEqualTo("Secret key is mandatory. No key secret found!"); + assertThat(mfeStatus.getStatus()).isNotNull().isEqualTo(KeycloakClientStatus.Status.ERROR); + }); + + // test error when secret has the right key but the value is empty + secret.setData(Map.of(CLIENT_PWD_KEY, "")); + + log.info("Update secret object {}", secret); + client.resource(secret).update(); + + log.info("Waiting 4 seconds and status is ERROR"); + + await().pollDelay(4, SECONDS).untilAsserted(() -> { + KeycloakClientStatus mfeStatus = client.resource(data1).get().getStatus(); + assertThat(mfeStatus).isNotNull(); + assertThat(mfeStatus.getMessage()).isEqualTo("Secret key '" + CLIENT_PWD_KEY + "' is mandatory. No value found!"); + assertThat(mfeStatus.getStatus()).isNotNull().isEqualTo(KeycloakClientStatus.Status.ERROR); + }); + } +} diff --git a/src/test/java/org/tkit/onecx/iam/kc/client/test/AbstractTest.java b/src/test/java/org/tkit/onecx/iam/kc/client/test/AbstractTest.java new file mode 100644 index 0000000..88932ee --- /dev/null +++ b/src/test/java/org/tkit/onecx/iam/kc/client/test/AbstractTest.java @@ -0,0 +1,36 @@ +package org.tkit.onecx.iam.kc.client.test; + +import org.eclipse.microprofile.jwt.Claims; +import org.jose4j.jws.JsonWebSignature; +import org.jose4j.jwt.JwtClaims; +import org.jose4j.jwx.JsonWebStructure; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import io.smallrye.jwt.auth.principal.DefaultJWTCallerPrincipal; + +public abstract class AbstractTest { + + final static Logger log = LoggerFactory.getLogger(AbstractTest.class); + + public static String REALM_QUARKUS = "quarkus"; + public static String USER_ALICE = "alice"; + public static String USER_BOB = "bob"; + + public static String UI_TOKEN_CLIENT_CLAIM_NAME = Claims.azp.name(); + + public static String MACHINE_TOKEN_CLIENT_CLAIM_NAME = "client_id"; + public static String SCOPE_CLAIM_NAME = "scope"; + + public DefaultJWTCallerPrincipal resolveToken(String token) { + try { + var jws = (JsonWebSignature) JsonWebStructure.fromCompactSerialization(token); + var jwtClaims = JwtClaims.parse(jws.getUnverifiedPayload()); + + return new DefaultJWTCallerPrincipal(token, jws.getKeyType(), jwtClaims); + } catch (Exception e) { + log.error("Error parse token {}", token); + } + return null; + } +}