diff --git a/.gitignore b/.gitignore index b7075732..aaef1a39 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,10 @@ bin *.swo *~ buildtools/yq + +# binaries +manager +group-sync-operator + +# temporary files +tmp/ diff --git a/Dockerfile b/Dockerfile index f7302d21..fa2ddd60 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # Build the manager binary -FROM golang:1.18 as builder +FROM golang:1.21 as builder WORKDIR /workspace # Copy the Go Modules manifests diff --git a/README.md b/README.md index db13fa51..7c542c1b 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,7 @@ Integration with external systems is made possible through a set of pluggable ex * [LDAP](https://en.wikipedia.org/wiki/Lightweight_Directory_Access_Protocol) * [Keycloak](https://www.keycloak.org/)/[Red Hat Single Sign On](https://access.redhat.com/products/red-hat-single-sign-on) * [Okta](https://www.okta.com/) +* [IBM Security Verify](https://docs.verify.ibm.com/verify) The following sections describe the configuration options available for each provider @@ -477,6 +478,54 @@ The secret can be created by executing the following command: oc create secret generic okta-api-token --from-literal=okta-api-token= -n group-sync-operator ``` +### IBM Security Verify + +Groups defined in [IBM Security Verify](https://help.okta.com/en/prod/Content/Topics/users-groups-profiles/usgp-main.htm) (ISV) can be synchronized into OpenShift. Currently only the `userName` field from ISV will be synchronized. The developer docs for the ISV API can be found [here](https://docs.verify.ibm.com/verify/page/api-documentation). +The following table describes the set of configuration options for the provider: + +| Name | Description | Defaults | Required | +| ----- | ---------- | -------- | ----- | +| `credentialsSecret` | Reference to a secret containing authentication details (see below) | `''` | Yes | +| `groups` | List of groups to synchronize (see below) | `nil` | Yes | +| `tenantUrl` | The ISV tenant URL, for example `https://my-isv.verify.ibm.com`) | `''` | Yes | + +The following is an example of a minimal configuration that can be applied to integrate with an Okta provider: + +```yaml +apiVersion: redhatcop.redhat.io/v1alpha1 +kind: GroupSync +metadata: + name: ibmsecurityverify-sync +spec: + providers: + - name: ibmsecurityverify + ibmsecurityverify: + credentialsSecret: + name: isv-group-sync + namespace: group-sync-operator + tenantUrl: https://my-isv.verify.ibm.com + groups: + - name: 'application owners' + id: 645001V3V9 + - name: developer + id: 645001V3VA +``` + +#### Group Objects +Each group object in the `groups` array must contain an `id` field. The group ID can be retrieved by pulling the group information from the ISV API. Optionally, the object may also contain a `name` which corresponds to the group's display name. When defined, the operator will confirm that the name defined in the YAML matches that received from the API when synchronization occurs; as the group IDs are not human-friendly, using the name can confirm the correct groups are configured. If the names do not match an error will be logged. + +#### Group Names +The name of each groups created in OpenShift will match the group name in ISV. Any whitespace in the ISV group name will be replaced with a hyphen. + +#### Authenticating to IBM Security Verify + +A secret must be created in the same namespace as the group-sync-operator pod. It must contain the following keys: + +* `clientId` - The API client ID. +* `clientSecret`- The API client secret. + +See the IBM Security Verify [API documentation](https://docs.verify.ibm.com/verify/docs/api-access) for setting up authentication. + ### Support for Additional Metadata (Beta) Additional metadata based on Keycloak group are also added to the OpenShift groups as Annotations including: diff --git a/api/v1alpha1/groupsync_types.go b/api/v1alpha1/groupsync_types.go index ff8e7a56..a655b11c 100644 --- a/api/v1alpha1/groupsync_types.go +++ b/api/v1alpha1/groupsync_types.go @@ -138,6 +138,11 @@ type ProviderType struct { // +operator-sdk:csv:customresourcedefinitions:type=spec,displayName="Okta Provider" // +kubebuilder:validation:Optional Okta *OktaProvider `json:"okta,omitempty"` + + // IbmSecurityVerify represents the IBM Security Verify provider + // +operator-sdk:csv:customresourcedefinitions:type=spec,displayName="IBM Security Verify" + // +kubebuilder:validation:Optional + IbmSecurityVerify *IbmSecurityVerifyProvider `json:"ibmsecurityverify,omitempty"` } // KeycloakProvider represents integration with Keycloak @@ -462,6 +467,35 @@ type OktaProvider struct { Prune bool `json:"prune"` } +// IbmSecurityVerifyProvider represents integration with IBM Security Verify +// +k8s:openapi-gen=true +type IbmSecurityVerifyProvider struct { + // CredentialsSecret is a reference to a secret containing authentication details for the IBM Security Verify server + // +operator-sdk:csv:customresourcedefinitions:type=spec,displayName="Secret Containing the Credentials",xDescriptors={"urn:alm:descriptor:io.kubernetes:Secret"} + // +kubebuilder:validation:Required + CredentialsSecret *ObjectRef `json:"credentialsSecret"` + // Groups is the list of ISV groups to synchronize + // +operator-sdk:csv:customresourcedefinitions:type=spec,displayName="Groups to Synchronize",xDescriptors={"urn:alm:descriptor:com.tectonic.ui:text"} + // +kubebuilder:validation:Required + Groups []IsvGroupSpec `json:"groups,omitempty"` + // TenantURL is the location of the IBM Security Verify tenant + // +operator-sdk:csv:customresourcedefinitions:type=spec,displayName="Tenant URL",xDescriptors={"urn:alm:descriptor:com.tectonic.ui:text"} + // +kubebuilder:validation:Required + TenantURL string `json:"tenantUrl"` +} + +// +k8s:openapi-gen=true +type IsvGroupSpec struct { + // The display name of the group as defined in IBM Security Verify + // +operator-sdk:csv:customresourcedefinitions:type=spec,displayName="Name",xDescriptors={"urn:alm:descriptor:com.tectonic.ui:text"} + // +kubebuilder:validation:Required + Name string `json:"name,omitempty"` + // The ID of the group as defined in IBM Security Verify. This value can be found by using the API. + // +operator-sdk:csv:customresourcedefinitions:type=spec,displayName="Id",xDescriptors={"urn:alm:descriptor:com.tectonic.ui:text"} + // +kubebuilder:validation:Required + Id string `json:"id,omitempty"` +} + // ObjectRef represents a reference to an item within a Secret // +k8s:openapi-gen=true type ObjectRef struct { diff --git a/config/crd/bases/redhatcop.redhat.io_groupsyncs.yaml b/config/crd/bases/redhatcop.redhat.io_groupsyncs.yaml index 8a87e681..6ad9936c 100644 --- a/config/crd/bases/redhatcop.redhat.io_groupsyncs.yaml +++ b/config/crd/bases/redhatcop.redhat.io_groupsyncs.yaml @@ -440,6 +440,54 @@ spec: - realm - url type: object + ibmsecurityverify: + description: The IBM Security Verify (ISV) provider + properties: + credentialsSecret: + description: CredentialsSecret is a reference to a secret containing authentication details for the ISV server + properties: + key: + description: Key represents the specific key to reference from the resource + type: string + kind: + default: Secret + description: Kind is a string value representing the resource type + enum: + - ConfigMap + - Secret + type: string + name: + description: Name represents the name of the resource + type: string + namespace: + description: Namespace represents the namespace containing the resource + type: string + required: + - name + - namespace + type: object + groups: + description: The ISV groups to synchronize + type: array + items: + type: object + properties: + name: + description: Name of the ISV group + type: string + id: + description: ID of the ISV group + type: string + required: + - id + tenantUrl: + description: URL for the ISV server of the tenant + type: string + required: + - credentialsSecret + - tenantUrl + - groups + type: object ldap: description: Ldap represents the LDAP provider properties: diff --git a/config/manifests/bases/group-sync-operator.clusterserviceversion.yaml b/config/manifests/bases/group-sync-operator.clusterserviceversion.yaml index ee131f3b..6447dcf1 100644 --- a/config/manifests/bases/group-sync-operator.clusterserviceversion.yaml +++ b/config/manifests/bases/group-sync-operator.clusterserviceversion.yaml @@ -766,6 +766,7 @@ spec: * [LDAP](https://en.wikipedia.org/wiki/Lightweight_Directory_Access_Protocol) * [Keycloak](https://www.keycloak.org/)/[Red Hat Single Sign On](https://access.redhat.com/products/red-hat-single-sign-on) * [Okta](https://www.okta.com/) + * [IBM Security Verify](https://docs.verify.ibm.com/verify) The following sections describe the configuration options available for each provider @@ -1162,6 +1163,54 @@ spec: ```shell oc create secret generic okta-api-token --from-literal=okta-api-token= -n group-sync-operator ``` + + ### IBM Security Verify + + Groups defined in [IBM Security Verify](https://help.okta.com/en/prod/Content/Topics/users-groups-profiles/usgp-main.htm) (ISV) can be synchronized into OpenShift. Currently only the `userName` field from ISV will be synchronized. The developer docs for the ISV API can be found [here](https://docs.verify.ibm.com/verify/page/api-documentation). + The following table describes the set of configuration options for the provider: + + | Name | Description | Defaults | Required | + | ----- | ---------- | -------- | ----- | + | `credentialsSecret` | Reference to a secret containing authentication details (see below) | `''` | Yes | + | `groups` | List of groups to synchronize (see below) | `nil` | Yes | + | `tenantUrl` | The ISV tenant URL, for example `https://my-isv.verify.ibm.com`) | `''` | Yes | + + The following is an example of a minimal configuration that can be applied to integrate with an Okta provider: + + ```yaml + apiVersion: redhatcop.redhat.io/v1alpha1 + kind: GroupSync + metadata: + name: ibmsecurityverify-sync + spec: + providers: + - name: ibmsecurityverify + ibmsecurityverify: + credentialsSecret: + name: isv-group-sync + namespace: group-sync-operator + tenantUrl: https://my-isv.verify.ibm.com + groups: + - name: 'application owners' + id: 645001V3V9 + - name: developer + id: 645001V3VA + ``` + + #### Group Objects + Each group object in the `groups` array must contain an `id` field. The group ID can be retrieved by pulling the group information from the ISV API. Optionally, the object may also contain a `name` which corresponds to the group's display name. When defined, the operator will confirm that the name defined in the YAML matches that received from the API when synchronization occurs; as the group IDs are not human-friendly, using the name can confirm the correct groups are configured. If the names do not match an error will be logged. + + #### Group Names + The name of each groups created in OpenShift will match the group name in ISV. Any whitespace in the ISV group name will be replaced with a hyphen. + + #### Authenticating to IBM Security Verify + + A secret must be created in the same namespace as the group-sync-operator pod. It must contain the following keys: + + * `clientId` - The API client ID. + * `clientSecret`- The API client secret. + + See the IBM Security Verify [API documentation](https://docs.verify.ibm.com/verify/docs/api-access) for setting up authentication. ### Support for Additional Metadata (Beta) diff --git a/go.mod b/go.mod index edeeaf21..f3703cee 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/redhat-cop/group-sync-operator -go 1.21.10 +go 1.21.13 require ( github.com/Azure/azure-sdk-for-go/sdk/azcore v1.4.0 @@ -33,6 +33,8 @@ require ( sigs.k8s.io/controller-runtime v0.13.1 ) +require github.com/stretchr/objx v0.5.2 // indirect + require ( cloud.google.com/go v0.97.0 // indirect github.com/Azure/azure-sdk-for-go/sdk/internal v1.2.0 // indirect @@ -70,7 +72,7 @@ require ( github.com/google/go-querystring v1.1.0 // indirect github.com/google/gofuzz v1.1.0 // indirect github.com/google/uuid v1.3.0 // indirect - github.com/hashicorp/go-retryablehttp v0.7.1 // indirect + github.com/hashicorp/go-retryablehttp v0.7.7 // indirect github.com/hashicorp/golang-lru v0.5.4 // indirect github.com/huandu/xstrings v1.3.3 // indirect github.com/imdario/mergo v0.3.12 // indirect @@ -80,7 +82,7 @@ require ( github.com/kylelemons/godebug v1.1.0 // indirect github.com/mailru/easyjson v0.7.6 // indirect github.com/mattn/go-colorable v0.1.13 // indirect - github.com/mattn/go-isatty v0.0.16 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect github.com/matttproud/golang_protobuf_extensions v1.0.2-0.20181231171920-c182affec369 // indirect github.com/microsoft/kiota-abstractions-go v0.19.0 // indirect github.com/microsoft/kiota-serialization-form-go v0.9.1 // indirect @@ -107,7 +109,7 @@ require ( github.com/shurcooL/graphql v0.0.0-20181231061246-d48a9a75455f // indirect github.com/spf13/cast v1.3.1 // indirect github.com/spf13/pflag v1.0.5 // indirect - github.com/stretchr/testify v1.8.2 // indirect + github.com/stretchr/testify v1.9.0 // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect go.opentelemetry.io/otel v1.14.0 // indirect go.opentelemetry.io/otel/trace v1.14.0 // indirect @@ -117,7 +119,7 @@ require ( golang.org/x/crypto v0.17.0 // indirect golang.org/x/net v0.17.0 // indirect golang.org/x/oauth2 v0.0.0-20220722155238-128564f6959c // indirect - golang.org/x/sys v0.15.0 // indirect + golang.org/x/sys v0.20.0 // indirect golang.org/x/term v0.15.0 // indirect golang.org/x/time v0.0.0-20220722155302-e5dcc9cfc0b9 // indirect gomodules.xyz/jsonpatch/v2 v2.2.0 // indirect diff --git a/go.sum b/go.sum index 68b7c5c5..69f4b453 100644 --- a/go.sum +++ b/go.sum @@ -419,11 +419,14 @@ github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9n github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= github.com/hashicorp/go-hclog v0.9.2 h1:CG6TE5H9/JXsFWJCfoIVpKFIkFe6ysEuHirp4DxCsHI= github.com/hashicorp/go-hclog v0.9.2/go.mod h1:5CU+agLiy3J7N7QjHK5d05KxGsuXiQLrjA0H7acj2lQ= +github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= github.com/hashicorp/go-immutable-radix v1.0.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= github.com/hashicorp/go-msgpack v0.5.3/go.mod h1:ahLV/dePpqEmjfWmKiqvPkv/twdG7iPBM1vqhUKIvfM= github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk= github.com/hashicorp/go-retryablehttp v0.7.1 h1:sUiuQAnLlbvmExtFQs72iFW/HXeUn8Z1aJLQ4LJJbTQ= github.com/hashicorp/go-retryablehttp v0.7.1/go.mod h1:vAew36LZh98gCBJNLH42IQ1ER/9wtLZZ8meHqQvEYWY= +github.com/hashicorp/go-retryablehttp v0.7.7 h1:C8hUCYzor8PIfXHa4UrZkU4VvK8o9ISHxT2Q8+VepXU= +github.com/hashicorp/go-retryablehttp v0.7.7/go.mod h1:pkQpWZeYWskR+D1tR2O5OcBFOxfA7DoAO6xtkuQnHTk= github.com/hashicorp/go-rootcerts v1.0.0/go.mod h1:K6zTfqpRlCUIjkwsN4Z+hiSfzSTQa6eBIzfwKfwNnHU= github.com/hashicorp/go-sockaddr v1.0.0/go.mod h1:7Xibr9yA9JjQq1JpNB2Vw7kxv8xerXegt+ozgdvDeDU= github.com/hashicorp/go-syslog v1.0.0/go.mod h1:qPfqrKkXGihmCqbJM2mZgkZGvKG1dFdvsLplgctolz4= @@ -509,6 +512,8 @@ github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hd github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= github.com/mattn/go-isatty v0.0.16 h1:bq3VjFmv/sOjHtdEhmkEV4x1AJtvUvOJ2PFAZ5+peKQ= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-runewidth v0.0.2/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= github.com/matttproud/golang_protobuf_extensions v1.0.2-0.20181231171920-c182affec369 h1:I0XW9+e1XWDxdcEniV4rQAIOPUGDq67JSCiRCgGCZLI= @@ -705,6 +710,8 @@ github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+ github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v0.0.0-20151208002404-e3a8ff8ce365/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= @@ -716,6 +723,8 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8= github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw= github.com/tidwall/pretty v1.0.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk= github.com/tmc/grpc-websocket-proxy v0.0.0-20170815181823-89b8d40f7ca8/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U= @@ -995,8 +1004,11 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc= golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc= diff --git a/pkg/provider/ibmsecurityverify/api_client.go b/pkg/provider/ibmsecurityverify/api_client.go new file mode 100644 index 00000000..178e496f --- /dev/null +++ b/pkg/provider/ibmsecurityverify/api_client.go @@ -0,0 +1,106 @@ +package ibmsecurityverify + +import ( + "encoding/json" + "fmt" + "net/http" + "net/url" + "strings" + + corev1 "k8s.io/api/core/v1" + logf "sigs.k8s.io/controller-runtime/pkg/log" +) + +var ( + logger = logf.Log.WithName("ibm_security_verify_api_client") +) + +type IbmSecurityVerifyClient interface { + SetHttpClient(client HttpClient) + SetCredentialsSecret(secret *corev1.Secret) + GetGroup(tenantUrl string, groupId string) IsvGroup +} + +type HttpClient interface { + Do(req *http.Request) (*http.Response, error) +} + +type ApiClient struct { + credentialsSecret *corev1.Secret + httpClient HttpClient +} + +type accessTokenResponse struct { + AccessToken string `json:"access_token"` + GrantId string `json:"grant_id"` + TokenType string `json:"token_type"` + ExpiresIn int `json:"expires_in"` +} + +func (apiClient *ApiClient) SetHttpClient(client HttpClient) { + apiClient.httpClient = client +} + +func (apiClient *ApiClient) SetCredentialsSecret(secret *corev1.Secret) { + apiClient.credentialsSecret = secret +} + +func (apiClient *ApiClient) GetGroup(tenantUrl string, groupId string) IsvGroup { + token := apiClient.fetchAccessToken(tenantUrl) + var group IsvGroup + if token != "" { + group = apiClient.fetchGroup(token, tenantUrl, groupId) + } + return group +} + +func (apiClient *ApiClient) fetchAccessToken(tenantUrl string) string { + tokenUrl := tenantUrl + "/v1.0/endpoint/default/token" + logger.Info(fmt.Sprintf("Requesting API access token from %s", tokenUrl)) + requestData := url.Values{} + requestData.Set("client_id", string(apiClient.credentialsSecret.Data["clientId"])) + requestData.Set("client_secret", string(apiClient.credentialsSecret.Data["clientSecret"])) + requestData.Set("grant_type", "client_credentials") + request, _ := http.NewRequest("POST", tokenUrl, strings.NewReader(requestData.Encode())) + request.Header.Add("content-type", "application/x-www-form-urlencoded") + response, err := apiClient.httpClient.Do(request) + var accessToken string + if err != nil || response.StatusCode != 200 { + logger.Error(err, fmt.Sprintf("Failed to request API access token. Response code: %d", response.StatusCode)) + } else { + decoder := json.NewDecoder(response.Body) + var data accessTokenResponse + err = decoder.Decode(&data) + if err == nil { + accessToken = data.AccessToken + logger.Info(fmt.Sprintf("Access token retrieved. Expires in %d seconds", data.ExpiresIn)) + } else { + logger.Error(err, "Failed to decode access token response") + } + } + defer response.Body.Close() + return accessToken +} + +func (apiClient *ApiClient) fetchGroup(accessToken string, tenantUrl string, groupName string) IsvGroup { + groupUrl := fmt.Sprintf("%s/v2.0/Groups/%s?membershipType=firstLevelUsersAndGroups", tenantUrl, groupName) + logger.Info(fmt.Sprintf("Requesting members from group '%s' from %s", groupName, groupUrl)) + request, err := http.NewRequest("GET", groupUrl, nil) + request.Header.Add("accept", "application/scim+json") + request.Header.Add("authorization", "bearer "+accessToken) + response, err := apiClient.httpClient.Do(request) + var group IsvGroup + if err != nil || response.StatusCode != 200 { + logger.Error(err, fmt.Sprintf("Failed to fetch group %s. Response code: %d", groupName, response.StatusCode)) + } else { + decoder := json.NewDecoder(response.Body) + err = decoder.Decode(&group) + if err != nil { + logger.Error(err, "Failed to decode group response") + } else { + logger.Info(fmt.Sprintf("ISV group '%s' (%s) retrieved and contains %d members", group.DisplayName, group.Id, len(group.Members))) + } + } + defer response.Body.Close() + return group +} diff --git a/pkg/provider/ibmsecurityverify/api_client_test.go b/pkg/provider/ibmsecurityverify/api_client_test.go new file mode 100644 index 00000000..3aa1f7a5 --- /dev/null +++ b/pkg/provider/ibmsecurityverify/api_client_test.go @@ -0,0 +1,115 @@ +package ibmsecurityverify + +import ( + "bytes" + "io" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + corev1 "k8s.io/api/core/v1" +) + +const ( + groupId = "testGroup" + groupDisplayName = "testDisplayName" + userId = "testUserId" + userExternalId = "testExternalId" + userName = "testUserName" +) + +type HttpClientMock struct { + mock.Mock +} + +func (client *HttpClientMock) Do(req *http.Request) (*http.Response, error) { + args := client.Called() + return args.Get(0).(*http.Response), args.Error(1) +} + +func TestGetGroupSuccess(t *testing.T) { + credentialsSecret := &corev1.Secret{} + credentialsSecret.Data = make(map[string][]byte) + credentialsSecret.Data["clientId"] = []byte("testClientId") + credentialsSecret.Data["clientSecret"] = []byte("testClientSecret") + + httpClient := new(HttpClientMock) + jsonResponse := `{ "access_token": "token", "grant_id": "grantId", "token_type": "type", "expires_in": 10000 }` + mockAccessTokenResponse := &http.Response{ + StatusCode: 200, + Body: io.NopCloser(bytes.NewReader([]byte(jsonResponse))), + } + httpClient.On("Do").Return(mockAccessTokenResponse, nil).Once() + + jsonResponse = `{ "id": "testGroup", "displayName": "testDisplayName", "members": [{ "id": "testUserId", "externalId": "testExternalId", "userName": "testUserName" }] }` + mockGroupResponse := &http.Response{ + StatusCode: 200, + Body: io.NopCloser(bytes.NewReader([]byte(jsonResponse))), + } + httpClient.On("Do").Return(mockGroupResponse, nil).Once() + + client := ApiClient{} + client.SetHttpClient(httpClient) + client.SetCredentialsSecret(credentialsSecret) + group := client.GetGroup("https://test.ibm.com", "testGroup") + if assert.NotNil(t, group) { + assert.Equal(t, groupId, group.Id) + assert.Equal(t, groupDisplayName, group.DisplayName) + } +} + +func TestGetGroupFailureOnFetchingAccessToken(t *testing.T) { + credentialsSecret := &corev1.Secret{} + credentialsSecret.Data = make(map[string][]byte) + credentialsSecret.Data["clientId"] = []byte("testClientId") + credentialsSecret.Data["clientSecret"] = []byte("testClientSecret") + + httpClient := new(HttpClientMock) + jsonResponse := `{ "accessToken": "token", "grantId": "grantId", "tokenType": "type", "expiresIn": 10000 }` + mockAccessTokenResponse := &http.Response{ + StatusCode: 400, + Body: io.NopCloser(bytes.NewReader([]byte(jsonResponse))), + } + httpClient.On("Do").Return(mockAccessTokenResponse, nil).Once() + + client := ApiClient{} + client.SetHttpClient(httpClient) + client.SetCredentialsSecret(credentialsSecret) + group := client.GetGroup("https://test.ibm.com", "testGroup") + if assert.NotNil(t, group) { + assert.Equal(t, "", group.Id) + assert.Equal(t, "", group.DisplayName) + } +} + +func TestGetGroupFailureOnFetchingGroup(t *testing.T) { + credentialsSecret := &corev1.Secret{} + credentialsSecret.Data = make(map[string][]byte) + credentialsSecret.Data["clientId"] = []byte("testClientId") + credentialsSecret.Data["clientSecret"] = []byte("testClientSecret") + + httpClient := new(HttpClientMock) + jsonResponse := `{ "accessToken": "token", "grantId": "grantId", "tokenType": "type", "expiresIn": 10000 }` + mockAccessTokenResponse := &http.Response{ + StatusCode: 200, + Body: io.NopCloser(bytes.NewReader([]byte(jsonResponse))), + } + httpClient.On("Do").Return(mockAccessTokenResponse, nil).Once() + + jsonResponse = `{ "error": "test" }` + mockGroupResponse := &http.Response{ + StatusCode: 400, + Body: io.NopCloser(bytes.NewReader([]byte(jsonResponse))), + } + httpClient.On("Do").Return(mockGroupResponse, nil).Once() + + client := ApiClient{} + client.SetHttpClient(httpClient) + client.SetCredentialsSecret(credentialsSecret) + group := client.GetGroup("https://test.ibm.com", "testGroup") + if assert.NotNil(t, group) { + assert.Equal(t, "", group.Id) + assert.Equal(t, "", group.DisplayName) + } +} diff --git a/pkg/provider/ibmsecurityverify/group.go b/pkg/provider/ibmsecurityverify/group.go new file mode 100644 index 00000000..3cb92bee --- /dev/null +++ b/pkg/provider/ibmsecurityverify/group.go @@ -0,0 +1,13 @@ +package ibmsecurityverify + +type IsvGroup struct { + Id string `json:"id,omitempty"` + DisplayName string `json:"displayName,omitempty"` + Members []IsvGroupMember `json:"members,omitempty"` +} + +type IsvGroupMember struct { + Id string `json:"id,omitempty"` + ExternalId string `json:"externalId,omitempty"` + UserName string `json:"userName,omitempty"` +} diff --git a/pkg/syncer/ibmsecurityverify.go b/pkg/syncer/ibmsecurityverify.go new file mode 100644 index 00000000..1a95665a --- /dev/null +++ b/pkg/syncer/ibmsecurityverify.go @@ -0,0 +1,122 @@ +package syncer + +import ( + "context" + "fmt" + "net/url" + "strings" + + retryablehttp "github.com/hashicorp/go-retryablehttp" + userv1 "github.com/openshift/api/user/v1" + redhatcopv1alpha1 "github.com/redhat-cop/group-sync-operator/api/v1alpha1" + "github.com/redhat-cop/group-sync-operator/pkg/constants" + "github.com/redhat-cop/group-sync-operator/pkg/provider/ibmsecurityverify" + "github.com/redhat-cop/operator-utils/pkg/util" + corev1 "k8s.io/api/core/v1" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + utilerrors "k8s.io/apimachinery/pkg/util/errors" + logf "sigs.k8s.io/controller-runtime/pkg/log" +) + +var ( + isvLogger = logf.Log.WithName("syncer_ibmsecurityverify") +) + +type IbmSecurityVerifySyncer struct { + Name string + GroupSync *redhatcopv1alpha1.GroupSync + Provider *redhatcopv1alpha1.IbmSecurityVerifyProvider + Context context.Context + ReconcilerBase util.ReconcilerBase + ApiClient ibmsecurityverify.IbmSecurityVerifyClient +} + +func (g *IbmSecurityVerifySyncer) Init() bool { + g.Context = context.Background() + return false +} + +func (g *IbmSecurityVerifySyncer) Validate() error { + validationErrors := []error{} + credentialsSecret := &corev1.Secret{} + err := g.ReconcilerBase.GetClient().Get(g.Context, types.NamespacedName{Name: g.Provider.CredentialsSecret.Name, Namespace: g.Provider.CredentialsSecret.Namespace}, credentialsSecret) + if err != nil { + validationErrors = append(validationErrors, err) + } else { + // Check that provided secret contains required keys + _, clientIdFound := credentialsSecret.Data[secretClientIdKey] + _, clientSecretFound := credentialsSecret.Data[secretClientSecretKey] + + if !clientIdFound && !clientSecretFound { + validationErrors = append(validationErrors, fmt.Errorf("Could not find `clientId` and `clientSecret` secret '%s' in namespace '%s'", g.Provider.CredentialsSecret.Name, g.Provider.CredentialsSecret.Namespace)) + } + + g.ApiClient.SetCredentialsSecret(credentialsSecret) + } + + if g.Provider.TenantURL == "" { + validationErrors = append(validationErrors, fmt.Errorf("tenant URL not provided")) + } + + if len(g.Provider.Groups) == 0 { + validationErrors = append(validationErrors, fmt.Errorf("ISV groups not provided")) + } + + return utilerrors.NewAggregate(validationErrors) +} + +func (g *IbmSecurityVerifySyncer) Bind() error { + retryClient := retryablehttp.NewClient() + retryClient.RetryMax = 10 + g.ApiClient.SetHttpClient(retryClient.StandardClient()) + return nil +} + +func (g *IbmSecurityVerifySyncer) Sync() ([]userv1.Group, error) { + ocpGroups := []userv1.Group{} + for _, group := range g.Provider.Groups { + isvGroup := g.ApiClient.GetGroup(g.Provider.TenantURL, group.Id) + g.validateGroupName(isvGroup, group.Name) + if isvGroup.Id != "" { + ocpGroup := userv1.Group{ + TypeMeta: v1.TypeMeta{ + Kind: "Group", + APIVersion: userv1.GroupVersion.String(), + }, + ObjectMeta: v1.ObjectMeta{ + Name: g.normalizeName(isvGroup.DisplayName), + Annotations: map[string]string{}, + Labels: map[string]string{}, + }, + Users: []string{}, + } + sourceUrl, _ := url.Parse(g.Provider.TenantURL) + ocpGroup.GetAnnotations()[constants.SyncSourceHost] = sourceUrl.Host + ocpGroup.GetAnnotations()[constants.SyncSourceUID] = isvGroup.Id + for _, member := range isvGroup.Members { + ocpGroup.Users = append(ocpGroup.Users, member.UserName) + } + ocpGroups = append(ocpGroups, ocpGroup) + } + } + return ocpGroups, nil +} + +func (g *IbmSecurityVerifySyncer) GetProviderName() string { + return g.Name +} + +func (g *IbmSecurityVerifySyncer) GetPrune() bool { + return false +} + +func (g *IbmSecurityVerifySyncer) normalizeName(name string) string { + return strings.ReplaceAll(name, " ", "-") +} + +func (g *IbmSecurityVerifySyncer) validateGroupName(group ibmsecurityverify.IsvGroup, expectedName string) { + if expectedName != "" && (group.DisplayName != expectedName) { + isvLogger.Error(nil, fmt.Sprintf("Retrieved group name '%s' does not match name '%s' in config", group.DisplayName, expectedName)) + } +} diff --git a/pkg/syncer/syncer.go b/pkg/syncer/syncer.go index 74e06c77..b85be0fd 100644 --- a/pkg/syncer/syncer.go +++ b/pkg/syncer/syncer.go @@ -6,6 +6,7 @@ import ( userv1 "github.com/openshift/api/user/v1" redhatcopv1alpha1 "github.com/redhat-cop/group-sync-operator/api/v1alpha1" + "github.com/redhat-cop/group-sync-operator/pkg/provider/ibmsecurityverify" "github.com/redhat-cop/operator-utils/pkg/util" "github.com/robfig/cron/v3" corev1 "k8s.io/api/core/v1" @@ -15,13 +16,15 @@ import ( ) const ( - secretUsernameKey = "username" - secretPasswordKey = "password" - secretTokenKey = "token" - secretTokenTypeKey = "tokenType" - privateKey = "privateKey" - appId = "appId" - defaultResourceCaKey = "ca.crt" + secretUsernameKey = "username" + secretPasswordKey = "password" + secretTokenKey = "token" + secretTokenTypeKey = "tokenType" + secretClientIdKey = "clientId" + secretClientSecretKey = "clientSecret" + privateKey = "privateKey" + appId = "appId" + defaultResourceCaKey = "ca.crt" ) type GroupSyncer interface { @@ -85,8 +88,12 @@ func getGroupSyncerForProvider(groupSync *redhatcopv1alpha1.GroupSync, provider { return &LdapSyncer{GroupSync: groupSync, Provider: provider.Ldap, Name: provider.Name, ReconcilerBase: reconcilerBase}, nil } + case provider.IbmSecurityVerify != nil: + { + apiClient := &ibmsecurityverify.ApiClient{} + return &IbmSecurityVerifySyncer{GroupSync: groupSync, Provider: provider.IbmSecurityVerify, Name: provider.Name, ReconcilerBase: reconcilerBase, ApiClient: apiClient}, nil + } } - return nil, fmt.Errorf("Could not find syncer for provider '%s'", provider.Name) }