diff --git a/pkg/testsuites/standard_suites.go b/pkg/testsuites/standard_suites.go index 533bdd270aa0..6a298e04cc92 100644 --- a/pkg/testsuites/standard_suites.go +++ b/pkg/testsuites/standard_suites.go @@ -430,6 +430,16 @@ var staticSuites = []ginkgo.TestSuite{ }, TestTimeout: 60 * time.Minute, }, + { + Name: "openshift/auth/external-oidc", + Description: templates.LongDesc(` + This test suite runs tests to validate cluster behavior when cluster authentication is configured to use an external OIDC provider. + `), + Qualifiers: []string{ + `name.contains("[Suite:openshift/auth/external-oidc") && !name.contains("[Skipped]")`, + }, + TestTimeout: 120 * time.Minute, + }, } func withExcludedTestsFilter(baseExpr string) string { diff --git a/test/extended/authentication/keycloak_client.go b/test/extended/authentication/keycloak_client.go new file mode 100644 index 000000000000..aab934f9456f --- /dev/null +++ b/test/extended/authentication/keycloak_client.go @@ -0,0 +1,377 @@ +package authentication + +import ( + "bytes" + "crypto/tls" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + + "k8s.io/apimachinery/pkg/runtime" +) + +type keycloakClient struct { + realm string + client *http.Client + adminURL *url.URL + + accessToken string + idToken string +} + +func keycloakClientFor(keycloakURL string) (*keycloakClient, error) { + baseURL, err := url.Parse(keycloakURL) + if err != nil { + return nil, fmt.Errorf("parsing url: %w", err) + } + + transport := &http.Transport{ + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: true, + }, + } + + return &keycloakClient{ + realm: "master", + client: &http.Client{ + Transport: transport, + }, + adminURL: baseURL.JoinPath("admin", "realms", "master"), + }, nil +} + +type group struct { + Name string `json:"name"` +} + +func (kc *keycloakClient) CreateGroup(name string) error { + groupURL := kc.adminURL.JoinPath("groups") + + group := group{ + Name: name, + } + + groupBytes, err := json.Marshal(group) + if err != nil { + return fmt.Errorf("marshalling group configuration %v", group) + } + + resp, err := kc.DoRequest(http.MethodPost, groupURL.String(), runtime.ContentTypeJSON, true, bytes.NewBuffer(groupBytes)) + if err != nil { + return fmt.Errorf("sending POST request to %q to create group %s", groupURL.String(), name) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusCreated { + respBytes, _ := io.ReadAll(resp.Body) + return fmt.Errorf("failed creating group %q: %s - %s", name, resp.Status, respBytes) + } + + return nil +} + +type user struct { + Username string `json:"username"` + Email string `json:"email"` + Enabled bool `json:"enabled"` + EmailVerified bool `json:"emailVerified"` + Groups []string `json:"groups"` + Credentials []credential `json:"credentials"` +} + +type credential struct { + Temporary bool `json:"temporary"` + Type credentialType `json:"type"` + Value string `json:"value"` +} + +type credentialType string + +const ( + credentialTypePassword credentialType = "password" +) + +func (kc *keycloakClient) CreateUser(username, password string, groups ...string) error { + userURL := kc.adminURL.JoinPath("users") + + user := user{ + Username: username, + Email: fmt.Sprintf("%s@payload.openshift.io", username), + Enabled: true, + EmailVerified: true, + Groups: groups, + Credentials: []credential{ + { + Temporary: true, + Type: credentialTypePassword, + Value: password, + }, + }, + } + + userBytes, err := json.Marshal(user) + if err != nil { + return fmt.Errorf("marshalling user configuration %v", user) + } + + resp, err := kc.DoRequest(http.MethodPost, userURL.String(), runtime.ContentTypeJSON, true, bytes.NewBuffer(userBytes)) + if err != nil { + return fmt.Errorf("sending POST request to %q to create user %v", userURL.String(), user) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusCreated { + respBytes, _ := io.ReadAll(resp.Body) + return fmt.Errorf("failed creating user %v: %s - %s", user, resp.Status, respBytes) + } + + return nil +} + +type authenticationResponse struct { + AccessToken string `json:"access_token"` + IDToken string `json:"id_token"` +} + +func (kc *keycloakClient) Authenticate(clientID, username, password string) error { + data := url.Values{} + data.Set("username", username) + data.Set("password", password) + data.Set("grant_type", "password") + data.Set("client_id", clientID) + data.Set("scope", "openid") + + tokenURL := *kc.adminURL + tokenURL.Path = fmt.Sprintf("/realms/%s/protocol/openid-connect/token", kc.realm) + + resp, err := kc.DoRequest(http.MethodPost, tokenURL.String(), "application/x-www-form-urlencoded", false, bytes.NewBuffer([]byte(data.Encode()))) + if err != nil { + return fmt.Errorf("authenticating as user %q: %w", username, err) + } + defer resp.Body.Close() + + respBody := &authenticationResponse{} + + err = json.NewDecoder(resp.Body).Decode(respBody) + if err != nil { + return fmt.Errorf("unmarshalling response data: %w", err) + } + + kc.accessToken = respBody.AccessToken + kc.idToken = respBody.IDToken + + return nil +} + +func (kc *keycloakClient) DoRequest(method, url, contentType string, authenticated bool, body io.Reader) (*http.Response, error) { + if len(kc.accessToken) == 0 && authenticated { + panic("must authenticate before calling keycloakClient.DoRequest") + } + + req, err := http.NewRequest(method, url, body) + if err != nil { + return nil, fmt.Errorf("building request: %w", err) + } + + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", kc.accessToken)) + req.Header.Set("Content-Type", contentType) + req.Header.Set("Accept", runtime.ContentTypeJSON) + + return kc.client.Do(req) +} + +func (kc *keycloakClient) AccessToken() string { + return kc.accessToken +} + +func (kc *keycloakClient) IdToken() string { + return kc.idToken +} + +func (kc *keycloakClient) ConfigureClient(clientId string) error { + client, err := kc.GetClientByClientID(clientId) + if err != nil { + return fmt.Errorf("getting client %q: %w", clientId, err) + } + + if err := kc.CreateClientGroupMapper(client.ID, "test-groups-mapper", "groups"); err != nil { + return fmt.Errorf("creating group mapper for client %q: %w", clientId, err) + } + + if err := kc.CreateClientAudienceMapper(client.ID, "test-aud-mapper"); err != nil { + return fmt.Errorf("creating audience mapper for client %q: %w", clientId, err) + } + + return nil +} + +type groupMapper struct { + Name string `json:"name"` + Protocol protocol `json:"protocol"` + ProtocolMapper protocolMapper `json:"protocolMapper"` + Config groupMapperConfig `json:"config"` +} + +type protocol string + +const ( + protocolOpenIDConnect protocol = "openid-connect" +) + +type protocolMapper string + +const ( + protocolMapperOpenIDConnectGroupMembership protocolMapper = "oidc-group-membership-mapper" + protocolMapperOpenIDConnectAudience protocolMapper = "oidc-audience-mapper" +) + +type groupMapperConfig struct { + FullPath booleanString `json:"full.path"` + IDTokenClaim booleanString `json:"id.token.claim"` + AccessTokenClaim booleanString `json:"access.token.claim"` + UserInfoTokenClaim booleanString `json:"userinfo.token.claim"` + ClaimName string `json:"claim.name"` +} + +type booleanString string + +const ( + booleanStringTrue booleanString = "true" + booleanStringFalse booleanString = "false" +) + +func (kc *keycloakClient) CreateClientGroupMapper(clientId, name, claim string) error { + mappersURL := *kc.adminURL + mappersURL.Path += fmt.Sprintf("/clients/%s/protocol-mappers/models", clientId) + + mapper := &groupMapper{ + Name: name, + Protocol: protocolOpenIDConnect, + ProtocolMapper: protocolMapperOpenIDConnectGroupMembership, + Config: groupMapperConfig{ + FullPath: booleanStringFalse, + IDTokenClaim: booleanStringTrue, + AccessTokenClaim: booleanStringTrue, + UserInfoTokenClaim: booleanStringTrue, + ClaimName: claim, + }, + } + + mapperBytes, err := json.Marshal(mapper) + if err != nil { + return err + } + + // Keycloak does not return the object on successful create so there's no need to attempt to retrieve it from the response + resp, err := kc.DoRequest(http.MethodPost, mappersURL.String(), runtime.ContentTypeJSON, true, bytes.NewBuffer(mapperBytes)) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusCreated { + respBytes, _ := io.ReadAll(resp.Body) + return fmt.Errorf("failed creating mapper %q: %s %s", name, resp.Status, respBytes) + } + + return nil +} + +type audienceMapper struct { + Name string `json:"name"` + Protocol protocol `json:"protocol"` + ProtocolMapper protocolMapper `json:"protocolMapper"` + Config audienceMapperConfig `json:"config"` +} + +type audienceMapperConfig struct { + IDTokenClaim booleanString `json:"id.token.claim"` + AccessTokenClaim booleanString `json:"access.token.claim"` + IntrospectionTokenClaim booleanString `json:"introspection.token.claim"` + IncludedClientAudience string `json:"included.client.audience"` + IncludedCustomAudience string `json:"included.custom.audience"` + LightweightClaim booleanString `json:"lightweight.claim"` +} + +func (kc *keycloakClient) CreateClientAudienceMapper(clientId, name string) error { + mappersURL := *kc.adminURL + mappersURL.Path += fmt.Sprintf("/clients/%s/protocol-mappers/models", clientId) + + mapper := &audienceMapper{ + Name: name, + Protocol: protocolOpenIDConnect, + ProtocolMapper: protocolMapperOpenIDConnectAudience, + Config: audienceMapperConfig{ + IDTokenClaim: booleanStringFalse, + AccessTokenClaim: booleanStringTrue, + IntrospectionTokenClaim: booleanStringTrue, + IncludedClientAudience: "admin-cli", + LightweightClaim: booleanStringFalse, + }, + } + + mapperBytes, err := json.Marshal(mapper) + if err != nil { + return err + } + + // Keycloak does not return the object on successful create so there's no need to attempt to retrieve it from the response + resp, err := kc.DoRequest(http.MethodPost, mappersURL.String(), runtime.ContentTypeJSON, true, bytes.NewBuffer(mapperBytes)) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusCreated { + respBytes, _ := io.ReadAll(resp.Body) + return fmt.Errorf("failed creating mapper %q: %s %s", name, resp.Status, respBytes) + } + + return nil +} + +type client struct { + ClientID string `json:"clientID"` + ID string `json:"id"` +} + +// ListClients retrieves all clients +func (kc *keycloakClient) ListClients() ([]client, error) { + clientsURL := *kc.adminURL + clientsURL.Path += "/clients" + + resp, err := kc.DoRequest(http.MethodGet, clientsURL.String(), runtime.ContentTypeJSON, true, nil) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("listing clients failed: %s", resp.Status) + } + + clients := []client{} + err = json.NewDecoder(resp.Body).Decode(&clients) + if err != nil { + return nil, fmt.Errorf("unmarshalling response data: %w", err) + } + + return clients, err +} + +func (kc *keycloakClient) GetClientByClientID(clientID string) (*client, error) { + clients, err := kc.ListClients() + if err != nil { + return nil, err + } + + for _, c := range clients { + if c.ClientID == clientID { + return &c, nil + } + } + + return nil, fmt.Errorf("client with clientID %q not found", clientID) +} diff --git a/test/extended/authentication/keycloak_helpers.go b/test/extended/authentication/keycloak_helpers.go new file mode 100644 index 000000000000..82d39e739c2b --- /dev/null +++ b/test/extended/authentication/keycloak_helpers.go @@ -0,0 +1,372 @@ +package authentication + +import ( + "context" + "fmt" + "path" + "time" + + routev1 "github.com/openshift/api/route/v1" + typedroutev1 "github.com/openshift/client-go/route/clientset/versioned/typed/route/v1" + exutil "github.com/openshift/origin/test/extended/util" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/intstr" + "k8s.io/apimachinery/pkg/util/wait" + typedappsv1 "k8s.io/client-go/kubernetes/typed/apps/v1" + typedcorev1 "k8s.io/client-go/kubernetes/typed/core/v1" + "k8s.io/utils/ptr" +) + +const ( + keycloakResourceName = "keycloak" + keycloakServingCertSecretName = "keycloak-serving-cert" + keycloakLabelKey = "app" + keycloakLabelValue = "keycloak" + keycloakHTTPSPort = 8443 + + // TODO: should this be an openshift image? + keycloakImage = "quay.io/keycloak/keycloak:25.0" + keycloakAdminUsername = "admin" + keycloakAdminPassword = "password" + keycloakCertVolumeName = "certkeypair" + keycloakCertMountPath = "/etc/x509/https" + keycloakCertFile = "tls.crt" + keycloakKeyFile = "tls.key" +) + +func deployKeycloak(ctx context.Context, client *exutil.CLI, namespace string) ([]removalFunc, error) { + cleanups := []removalFunc{} + + corev1Client := client.AdminKubeClient().CoreV1() + + cleanup, err := createKeycloakNamespace(ctx, corev1Client.Namespaces(), namespace) + if err != nil { + return cleanups, fmt.Errorf("creating namespace for keycloak: %w", err) + } + cleanups = append(cleanups, cleanup) + + cleanup, err = createKeycloakServiceAccount(ctx, corev1Client.ServiceAccounts(namespace)) + if err != nil { + return cleanups, fmt.Errorf("creating serviceaccount for keycloak: %w", err) + } + cleanups = append(cleanups, cleanup) + + service, cleanup, err := createKeycloakService(ctx, corev1Client.Services(namespace)) + if err != nil { + return cleanups, fmt.Errorf("creating service for keycloak: %w", err) + } + cleanups = append(cleanups, cleanup) + + cleanup, err = createKeycloakDeployment(ctx, client.AdminKubeClient().AppsV1().Deployments(namespace)) + if err != nil { + return cleanups, fmt.Errorf("creating deployment for keycloak: %w", err) + } + cleanups = append(cleanups, cleanup) + + cleanup, err = createKeycloakRoute(ctx, service, client.AdminRouteClient().RouteV1().Routes(namespace)) + if err != nil { + return cleanups, fmt.Errorf("creating route for keycloak: %w", err) + } + cleanups = append(cleanups, cleanup) + + cleanup, err = createKeycloakCAConfigMap(ctx, corev1Client) + if err != nil { + return cleanups, fmt.Errorf("creating CA configmap for keycloak: %w", err) + } + cleanups = append(cleanups, cleanup) + + return cleanups, waitForKeycloakAvailable(ctx, client, namespace) +} + +func createKeycloakNamespace(ctx context.Context, client typedcorev1.NamespaceInterface, namespace string) (removalFunc, error) { + ns := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: namespace, + }, + } + + _, err := client.Create(ctx, ns, metav1.CreateOptions{}) + if err != nil && !apierrors.IsAlreadyExists(err) { + return nil, fmt.Errorf("creating serviceaccount: %w", err) + } + + return func(ctx context.Context) error { + return client.Delete(ctx, ns.Name, metav1.DeleteOptions{}) + }, nil +} + +func createKeycloakServiceAccount(ctx context.Context, client typedcorev1.ServiceAccountInterface) (removalFunc, error) { + sa := &corev1.ServiceAccount{ + ObjectMeta: metav1.ObjectMeta{ + Name: keycloakResourceName, + }, + } + sa.SetGroupVersionKind(corev1.SchemeGroupVersion.WithKind("ServiceAccount")) + + _, err := client.Create(ctx, sa, metav1.CreateOptions{}) + if err != nil && !apierrors.IsAlreadyExists(err) { + return nil, fmt.Errorf("creating serviceaccount: %w", err) + } + + return func(ctx context.Context) error { + return client.Delete(ctx, sa.Name, metav1.DeleteOptions{}) + }, nil +} + +func createKeycloakService(ctx context.Context, client typedcorev1.ServiceInterface) (*corev1.Service, removalFunc, error) { + service := &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: keycloakResourceName, + Annotations: map[string]string{ + "service.beta.openshift.io/serving-cert-secret-name": keycloakServingCertSecretName, + }, + }, + Spec: corev1.ServiceSpec{ + Selector: keycloakLabels(), + Ports: []corev1.ServicePort{ + { + Name: "https", + Port: keycloakHTTPSPort, + }, + }, + }, + } + service.SetGroupVersionKind(corev1.SchemeGroupVersion.WithKind("Service")) + + _, err := client.Create(ctx, service, metav1.CreateOptions{}) + if err != nil && !apierrors.IsAlreadyExists(err) { + return nil, nil, fmt.Errorf("creating service: %w", err) + } + + return service, func(ctx context.Context) error { + return client.Delete(ctx, service.Name, metav1.DeleteOptions{}) + }, nil +} + +func createKeycloakCAConfigMap(ctx context.Context, client typedcorev1.ConfigMapsGetter) (removalFunc, error) { + defaultIngressCACM, err := client.ConfigMaps("openshift-config-managed").Get(ctx, "default-ingress-cert", metav1.GetOptions{}) + if err != nil { + return nil, fmt.Errorf("getting configmap openshift-config-managed/default-ingress-cert: %w", err) + } + + data := defaultIngressCACM.Data["ca-bundle.crt"] + + keycloakCACM := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: fmt.Sprintf("%s-ca", keycloakResourceName), + }, + Data: map[string]string{ + "ca-bundle.crt": data, + }, + } + keycloakCACM.SetGroupVersionKind(corev1.SchemeGroupVersion.WithKind("ConfigMap")) + + _, err = client.ConfigMaps("openshift-config").Create(ctx, keycloakCACM, metav1.CreateOptions{}) + if err != nil && !apierrors.IsAlreadyExists(err) { + return nil, fmt.Errorf("creating configmap: %w", err) + } + + return func(ctx context.Context) error { + return client.ConfigMaps("openshift-config").Delete(ctx, keycloakCACM.Name, metav1.DeleteOptions{}) + }, nil +} + +func createKeycloakDeployment(ctx context.Context, client typedappsv1.DeploymentInterface) (removalFunc, error) { + deployment := &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: keycloakResourceName, + Labels: keycloakLabels(), + }, + Spec: appsv1.DeploymentSpec{ + Selector: &metav1.LabelSelector{ + MatchLabels: keycloakLabels(), + }, + Replicas: ptr.To(int32(1)), + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Name: keycloakResourceName, + Labels: keycloakLabels(), + }, + Spec: corev1.PodSpec{ + Containers: keycloakContainers(), + Volumes: keycloakVolumes(), + }, + }, + }, + } + deployment.SetGroupVersionKind(appsv1.SchemeGroupVersion.WithKind("Deployment")) + + _, err := client.Create(ctx, deployment, metav1.CreateOptions{}) + if err != nil && !apierrors.IsAlreadyExists(err) { + return nil, fmt.Errorf("creating deployment: %w", err) + } + + return func(ctx context.Context) error { + return client.Delete(ctx, deployment.Name, metav1.DeleteOptions{}) + }, nil +} + +func keycloakLabels() map[string]string { + return map[string]string{ + keycloakLabelKey: keycloakLabelValue, + } +} + +func keycloakReadinessProbe() *corev1.Probe { + return &corev1.Probe{ + ProbeHandler: corev1.ProbeHandler{ + HTTPGet: &corev1.HTTPGetAction{ + Path: "/health/ready", + Port: intstr.FromInt(9000), + Scheme: corev1.URISchemeHTTPS, + }, + }, + InitialDelaySeconds: 10, + } +} + +func keycloakLivenessProbe() *corev1.Probe { + return &corev1.Probe{ + ProbeHandler: corev1.ProbeHandler{ + HTTPGet: &corev1.HTTPGetAction{ + Path: "/health/live", + Port: intstr.FromInt(9000), + Scheme: corev1.URISchemeHTTPS, + }, + }, + InitialDelaySeconds: 10, + } +} + +func keycloakEnvVars() []corev1.EnvVar { + return []corev1.EnvVar{ + { + Name: "KEYCLOAK_ADMIN", + Value: keycloakAdminUsername, + }, + { + Name: "KEYCLOAK_ADMIN_PASSWORD", + Value: keycloakAdminPassword, + }, + { + Name: "KC_HEALTH_ENABLED", + Value: "true", + }, + { + Name: "KC_HOSTNAME_STRICT", + Value: "false", + }, + { + Name: "KC_PROXY", + Value: "reencrypt", + }, + { + Name: "KC_HTTPS_CERTIFICATE_FILE", + Value: path.Join(keycloakCertMountPath, keycloakCertFile), + }, + { + Name: "KC_HTTPS_CERTIFICATE_KEY_FILE", + Value: path.Join(keycloakCertMountPath, keycloakKeyFile), + }, + } +} + +func keycloakVolumes() []corev1.Volume { + return []corev1.Volume{ + { + Name: keycloakCertVolumeName, + VolumeSource: corev1.VolumeSource{ + Secret: &corev1.SecretVolumeSource{ + SecretName: keycloakServingCertSecretName, + }, + }, + }, + } +} + +func keycloakVolumeMounts() []corev1.VolumeMount { + return []corev1.VolumeMount{ + { + Name: keycloakCertVolumeName, + MountPath: keycloakCertMountPath, + ReadOnly: true, + }, + } +} + +func keycloakContainers() []corev1.Container { + return []corev1.Container{ + { + Name: "keycloak", + Image: keycloakImage, + Env: keycloakEnvVars(), + VolumeMounts: keycloakVolumeMounts(), + Ports: []corev1.ContainerPort{ + { + ContainerPort: keycloakHTTPSPort, + }, + }, + LivenessProbe: keycloakLivenessProbe(), + ReadinessProbe: keycloakReadinessProbe(), + Command: []string{ + "/opt/keycloak/bin/kc.sh", + "start-dev", + }, + }, + } +} + +func createKeycloakRoute(ctx context.Context, service *corev1.Service, client typedroutev1.RouteInterface) (removalFunc, error) { + route := &routev1.Route{ + ObjectMeta: metav1.ObjectMeta{ + Name: keycloakResourceName, + }, + Spec: routev1.RouteSpec{ + TLS: &routev1.TLSConfig{ + Termination: routev1.TLSTerminationReencrypt, + InsecureEdgeTerminationPolicy: routev1.InsecureEdgeTerminationPolicyRedirect, + }, + To: routev1.RouteTargetReference{ + Kind: "Service", + Name: service.Name, + }, + Port: &routev1.RoutePort{ + TargetPort: intstr.FromString("https"), + }, + }, + } + route.SetGroupVersionKind(routev1.SchemeGroupVersion.WithKind("Route")) + + _, err := client.Create(ctx, route, metav1.CreateOptions{}) + if err != nil && !apierrors.IsAlreadyExists(err) { + return nil, fmt.Errorf("creating route: %w", err) + } + + return func(ctx context.Context) error { + return client.Delete(ctx, route.Name, metav1.DeleteOptions{}) + }, nil +} + +func waitForKeycloakAvailable(ctx context.Context, client *exutil.CLI, namespace string) error { + timeoutCtx, cancel := context.WithDeadline(ctx, time.Now().Add(5*time.Minute)) + defer cancel() + err := wait.PollUntilContextCancel(timeoutCtx, 10*time.Second, true, func(ctx context.Context) (done bool, err error) { + deploy, err := client.AdminKubeClient().AppsV1().Deployments(namespace).Get(ctx, keycloakResourceName, metav1.GetOptions{}) + if err != nil { + return false, nil + } + + for _, condition := range deploy.Status.Conditions { + if condition.Type == appsv1.DeploymentAvailable && condition.Status == corev1.ConditionTrue { + return true, nil + } + } + + return false, nil + }) + + return err +} diff --git a/test/extended/authentication/oidc.go b/test/extended/authentication/oidc.go new file mode 100644 index 000000000000..3e834813a781 --- /dev/null +++ b/test/extended/authentication/oidc.go @@ -0,0 +1,547 @@ +package authentication + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "time" + + g "github.com/onsi/ginkgo/v2" + o "github.com/onsi/gomega" + configv1 "github.com/openshift/api/config/v1" + operatorv1 "github.com/openshift/api/operator/v1" + routev1 "github.com/openshift/api/route/v1" + exutil "github.com/openshift/origin/test/extended/util" + authnv1 "k8s.io/api/authentication/v1" + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/util/errors" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/util/rand" + "k8s.io/apimachinery/pkg/util/wait" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/rest" + "k8s.io/pod-security-admission/api" + + "github.com/openshift/library-go/pkg/operator/condition" +) + +type kubeObject interface { + runtime.Object + metav1.Object +} + +var _ = g.Describe("[sig-auth][Suite:openshift/auth/external-oidc][Serial][Slow][Disruptive]", g.Ordered, func() { + defer g.GinkgoRecover() + oc := exutil.NewCLIWithoutNamespace("oidc-e2e") + oc.KubeFramework().NamespacePodSecurityLevel = api.LevelPrivileged + oc.SetNamespace("oidc-e2e") + ctx := context.TODO() + + var cleanups []removalFunc + var keycloakCli *keycloakClient + var username string + var password string + var group string + var originalAuth *configv1.Authentication + var oauthUserConfig *rest.Config + + var keycloakNamespace string + + g.BeforeAll(func() { + var err error + + keycloakNamespace = fmt.Sprintf("oidc-keycloak-%s", rand.String(8)) + + cleanups, err = deployKeycloak(ctx, oc, keycloakNamespace) + o.Expect(err).NotTo(o.HaveOccurred(), "should not encounter an error deploying keycloak") + + kcURL, err := admittedURLForRoute(ctx, oc, keycloakResourceName, keycloakNamespace) + o.Expect(err).NotTo(o.HaveOccurred(), "should not encounter an error getting keycloak route URL") + + keycloakCli, err = keycloakClientFor(kcURL) + o.Expect(err).NotTo(o.HaveOccurred(), "should not encounter an error creating a keycloak client") + + // First authenticate as the admin keycloak user so we can add new groups and users + err = keycloakCli.Authenticate("admin-cli", keycloakAdminUsername, keycloakAdminPassword) + o.Expect(err).NotTo(o.HaveOccurred(), "should not encounter an error authenticating as keycloak admin") + + o.Expect(keycloakCli.ConfigureClient("admin-cli")).NotTo(o.HaveOccurred(), "should not encounter an error configuring the admin-cli client") + + username = rand.String(8) + password = rand.String(8) + group = fmt.Sprintf("ocp-test-%s-group", rand.String(8)) + + o.Expect(keycloakCli.CreateGroup(group)).To(o.Succeed(), "should be able to create a new keycloak group") + o.Expect(keycloakCli.CreateUser(username, password, group)).To(o.Succeed(), "should be able to create a new keycloak user") + + originalAuth, err = oc.AdminConfigClient().ConfigV1().Authentications().Get(ctx, "cluster", metav1.GetOptions{}) + o.Expect(err).NotTo(o.HaveOccurred(), "should not error getting authentications") + + oauthUserConfig = oc.GetClientConfigForUser("oidc-e2e-oauth-user") + }) + + g.Describe("[OCPFeatureGate:ExternalOIDC]", g.Ordered, func() { + g.BeforeAll(func() { + _, _, err := configureOIDCAuthentication(ctx, oc, keycloakNamespace, nil) + o.Expect(err).NotTo(o.HaveOccurred(), "should not encounter an error configuring OIDC authentication") + + waitForRollout(ctx, oc) + }) + + g.Describe("external IdP is configured", g.Ordered, func() { + g.It("should configure kube-apiserver", func() { + kas, err := oc.AdminOperatorClient().OperatorV1().KubeAPIServers().Get(ctx, "cluster", metav1.GetOptions{}) + o.Expect(err).NotTo(o.HaveOccurred(), "should not encounter an error getting the kubeapiservers.operator.openshift.io/cluster") + + observedConfig := map[string]interface{}{} + err = json.Unmarshal(kas.Spec.ObservedConfig.Raw, &observedConfig) + o.Expect(err).NotTo(o.HaveOccurred(), "should not encounter an error unmarshalling the KAS observed configuration") + + o.Expect(observedConfig["authConfig"]).To(o.BeNil(), "authConfig should not be specified when OIDC authentication is configured") + + apiServerArgs := observedConfig["apiServerArguments"].(map[string]interface{}) + + o.Expect(apiServerArgs["authentication-token-webhook-config-file"]).To(o.BeNil(), "authentication-token-webhook-config-file argument should not be specified when OIDC authentication is configured") + o.Expect(apiServerArgs["authentication-token-webhook-version"]).To(o.BeNil(), "authentication-token-webhook-version argument should not be specified when OIDC authentication is configured") + + o.Expect(apiServerArgs["authentication-config"]).NotTo(o.BeNil(), "authentication-config argument should be specified when OIDC authentication is configured") + o.Expect(apiServerArgs["authentication-config"].([]interface{})[0].(string)).To(o.Equal("/etc/kubernetes/static-pod-resources/configmaps/auth-config/auth-config.json")) + }) + + g.It("[Skipped] should remove the OpenShift OAuth stack", func() { + g.Skip("functionality not yet implemented") + /* + o.Eventually(func(gomega o.Gomega) { + _, err := oc.AdminKubeClient().AppsV1().Deployments("openshift-authentication").Get(ctx, "oauth-openshift", metav1.GetOptions{}) + gomega.Expect(err).NotTo(o.BeNil(), "should not be able to get the integrated oauth stack") + gomega.Expect(apierrors.IsNotFound(err)).To(o.BeTrue(), "integrated oauth stack should not be present when OIDC authentication is configured") + }).WithTimeout(20 * time.Minute).WithPolling(30 * time.Second).Should(o.Succeed()) + */ + }) + + g.It("should not accept tokens provided by the OAuth server", func() { + o.Eventually(func(gomega o.Gomega) { + clientset, err := kubernetes.NewForConfig(oauthUserConfig) + gomega.Expect(err).NotTo(o.HaveOccurred()) + + _, err = clientset.AuthenticationV1().SelfSubjectReviews().Create(ctx, &authnv1.SelfSubjectReview{ + ObjectMeta: metav1.ObjectMeta{ + Name: fmt.Sprintf("%s-info", username), + }, + }, metav1.CreateOptions{}) + gomega.Expect(err).ShouldNot(o.BeNil(), "should not be able to create SelfSubjectReview using OAuth client token") + gomega.Expect(apierrors.IsUnauthorized(err)).To(o.BeTrue(), "should receive an unauthorized error when trying to create SelfSubjectReview using OAuth client token") + }).WithTimeout(5 * time.Minute).WithPolling(10 * time.Second).Should(o.Succeed()) + }) + + g.It("should accept authentication via a certificate-based kubeconfig (break-glass)", func() { + _, err := oc.AdminKubeClient().CoreV1().Pods(oc.Namespace()).List(ctx, metav1.ListOptions{}) + o.Expect(err).NotTo(o.HaveOccurred(), "should be able to list pods using certificate-based authentication") + }) + + g.It("should map cluster identities correctly", func() { + // should always be able to create an SSR for yourself + o.Eventually(func(gomega o.Gomega) { + err := keycloakCli.Authenticate("admin-cli", username, password) + gomega.Expect(err).NotTo(o.HaveOccurred(), "should not encounter an error authenticating as keycloak user") + + copiedOC := *oc + tokenOC := copiedOC.WithToken(keycloakCli.AccessToken()) + ssr, err := tokenOC.KubeClient().AuthenticationV1().SelfSubjectReviews().Create(ctx, &authnv1.SelfSubjectReview{ + ObjectMeta: metav1.ObjectMeta{ + Name: fmt.Sprintf("%s-info", username), + }, + }, metav1.CreateOptions{}) + gomega.Expect(err).NotTo(o.HaveOccurred(), "should be able to create a SelfSubjectReview") + + gomega.Expect(ssr.Status.UserInfo.Username).To(o.Equal(fmt.Sprintf("%s@payload.openshift.io", username))) + gomega.Expect(ssr.Status.UserInfo.Groups).To(o.ContainElement(group)) + }).WithTimeout(5 * time.Minute).WithPolling(10 * time.Second).Should(o.Succeed()) + }) + }) + + g.Describe("reverting to IntegratedOAuth", g.Ordered, func() { + g.BeforeAll(func() { + // Wait until we can authenticate using the configured external IdP + o.Eventually(func(gomega o.Gomega) { + // always re-authenticate to get a new token + err := keycloakCli.Authenticate("admin-cli", username, password) + gomega.Expect(err).NotTo(o.HaveOccurred(), "should not encounter an error authenticating as keycloak user") + + copiedOC := *oc + tokenOC := copiedOC.WithToken(keycloakCli.AccessToken()) + + _, err = tokenOC.KubeClient().AuthenticationV1().SelfSubjectReviews().Create(ctx, &authnv1.SelfSubjectReview{ + ObjectMeta: metav1.ObjectMeta{ + Name: fmt.Sprintf("%s-info", username), + }, + }, metav1.CreateOptions{}) + gomega.Expect(err).NotTo(o.HaveOccurred(), "should be able to create a SelfSubjectReview") + }).WithTimeout(5 * time.Minute).WithPolling(10 * time.Second).Should(o.Succeed()) + + err := resetAuthentication(ctx, oc, originalAuth) + o.Expect(err).NotTo(o.HaveOccurred(), "should not encounter an error reverting authentication to original state") + + waitForRollout(ctx, oc) + }) + + g.It("should rollout configuration on the kube-apiserver successfully", func() { + kas, err := oc.AdminOperatorClient().OperatorV1().KubeAPIServers().Get(ctx, "cluster", metav1.GetOptions{}) + o.Expect(err).NotTo(o.HaveOccurred(), "should not encounter an error getting the kubeapiservers.operator.openshift.io/cluster") + + observedConfig := map[string]interface{}{} + err = json.Unmarshal(kas.Spec.ObservedConfig.Raw, &observedConfig) + o.Expect(err).NotTo(o.HaveOccurred(), "should not encounter an error unmarshalling the KAS observed configuration") + + o.Expect(observedConfig["authConfig"]).ToNot(o.BeNil(), "authConfig should be specified when OIDC authentication is configured") + + apiServerArgs := observedConfig["apiServerArguments"].(map[string]interface{}) + + o.Expect(apiServerArgs["authentication-token-webhook-config-file"]).NotTo(o.BeNil(), "authentication-token-webhook-config-file argument should be specified when OIDC authentication is not configured") + o.Expect(apiServerArgs["authentication-token-webhook-version"]).NotTo(o.BeNil(), "authentication-token-webhook-version argument should be specified when OIDC authentication is not configured") + + o.Expect(apiServerArgs["authentication-config"]).To(o.BeNil(), "authentication-config argument should not be specified when OIDC authentication is not configured") + }) + + g.It("should rollout the OpenShift OAuth stack", func() { + o.Eventually(func(gomega o.Gomega) { + _, err := oc.AdminKubeClient().AppsV1().Deployments("openshift-authentication").Get(ctx, "oauth-openshift", metav1.GetOptions{}) + gomega.Expect(err).Should(o.BeNil(), "should be able to get the integrated oauth stack") + }).WithTimeout(5 * time.Minute).WithPolling(10 * time.Second).Should(o.Succeed()) + }) + + g.It("should not accept tokens provided by an external IdP", func() { + o.Eventually(func(gomega o.Gomega) { + // always re-authenticate to get a new token + err := keycloakCli.Authenticate("admin-cli", username, password) + gomega.Expect(err).NotTo(o.HaveOccurred(), "should not encounter an error authenticating as keycloak user") + + copiedOC := *oc + tokenOC := copiedOC.WithToken(keycloakCli.AccessToken()) + + _, err = tokenOC.KubeClient().AuthenticationV1().SelfSubjectReviews().Create(ctx, &authnv1.SelfSubjectReview{ + ObjectMeta: metav1.ObjectMeta{ + Name: fmt.Sprintf("%s-info", username), + }, + }, metav1.CreateOptions{}) + gomega.Expect(err).To(o.HaveOccurred(), "should not be able to create a SelfSubjectReview") + gomega.Expect(apierrors.IsUnauthorized(err)).To(o.BeTrue(), "external IdP token should be unauthorized") + }).WithTimeout(5 * time.Minute).WithPolling(10 * time.Second).Should(o.Succeed()) + }) + + g.It("should accept tokens provided by the OpenShift OAuth server", func() { + o.Eventually(func(gomega o.Gomega) { + clientset, err := kubernetes.NewForConfig(oauthUserConfig) + gomega.Expect(err).NotTo(o.HaveOccurred()) + + _, err = clientset.AuthenticationV1().SelfSubjectReviews().Create(ctx, &authnv1.SelfSubjectReview{ + ObjectMeta: metav1.ObjectMeta{ + Name: fmt.Sprintf("%s-info", username), + }, + }, metav1.CreateOptions{}) + gomega.Expect(err).ShouldNot(o.HaveOccurred(), "should be able to create SelfSubjectReview using OAuth client token") + }).WithTimeout(5 * time.Minute).WithPolling(10 * time.Second).Should(o.Succeed()) + }) + }) + }) + + g.Describe("[OCPFeatureGate:ExternalOIDCWithUIDAndExtraClaimMappings]", g.Ordered, func() { + g.Describe("external IdP is configured", func() { + g.Describe("without specified UID or Extra claim mappings", func() { + g.BeforeAll(func() { + _, _, err := configureOIDCAuthentication(ctx, oc, keycloakNamespace, nil) + o.Expect(err).NotTo(o.HaveOccurred(), "should not encounter an error configuring OIDC authentication") + + waitForRollout(ctx, oc) + }) + + g.It("should default UID to the 'sub' claim in the access token from the IdP", func() { + // should always be able to create an SSR for yourself + o.Eventually(func(gomega o.Gomega) { + err := keycloakCli.Authenticate("admin-cli", username, password) + gomega.Expect(err).NotTo(o.HaveOccurred(), "should not encounter an error authenticating as keycloak user") + + copiedOC := *oc + tokenOC := copiedOC.WithToken(keycloakCli.AccessToken()) + ssr, err := tokenOC.KubeClient().AuthenticationV1().SelfSubjectReviews().Create(ctx, &authnv1.SelfSubjectReview{ + ObjectMeta: metav1.ObjectMeta{ + Name: fmt.Sprintf("%s-info", username), + }, + }, metav1.CreateOptions{}) + gomega.Expect(err).NotTo(o.HaveOccurred(), "should be able to create a SelfSubjectReview") + + gomega.Expect(ssr.Status.UserInfo.UID).ToNot(o.BeEmpty()) + }).WithTimeout(5 * time.Minute).WithPolling(10 * time.Second).Should(o.Succeed()) + }) + }) + + g.Describe("with valid specified UID or Extra claim mappings", func() { + g.BeforeAll(func() { + _, _, err := configureOIDCAuthentication(ctx, oc, keycloakNamespace, func(o *configv1.OIDCProvider) { + o.ClaimMappings.UID = &configv1.TokenClaimOrExpressionMapping{ + Expression: "claims.preferred_username.upperAscii()", + } + + o.ClaimMappings.Extra = []configv1.ExtraMapping{ + { + Key: "payload/test", + ValueExpression: "claims.email + 'extra'", + }, + } + }) + o.Expect(err).NotTo(o.HaveOccurred(), "should not encounter an error configuring OIDC authentication") + + waitForRollout(ctx, oc) + }) + + g.Describe("checking cluster identity mapping", g.Ordered, func() { + ssr := &authnv1.SelfSubjectReview{} + g.BeforeAll(func() { + o.Eventually(func(gomega o.Gomega) { + err := keycloakCli.Authenticate("admin-cli", username, password) + gomega.Expect(err).NotTo(o.HaveOccurred(), "should not encounter an error authenticating as keycloak user") + + copiedOC := *oc + tokenOC := copiedOC.WithToken(keycloakCli.AccessToken()) + ssr, err = tokenOC.KubeClient().AuthenticationV1().SelfSubjectReviews().Create(ctx, &authnv1.SelfSubjectReview{ + ObjectMeta: metav1.ObjectMeta{ + Name: fmt.Sprintf("%s-info", username), + }, + }, metav1.CreateOptions{}) + gomega.Expect(err).NotTo(o.HaveOccurred(), "should be able to create a SelfSubjectReview") + }).WithTimeout(5 * time.Minute).WithPolling(10 * time.Second).Should(o.Succeed()) + }) + + g.It("should map UID correctly", func() { + o.Expect(ssr.UID).NotTo(o.Equal(strings.ToUpper(username))) + }) + + g.It("should map Extra correctly", func() { + o.Expect(ssr.Status.UserInfo.Extra).To(o.HaveKey("payload/test")) + o.Expect(ssr.Status.UserInfo.Extra["payload/test"]).To(o.HaveLen(1)) + o.Expect(ssr.Status.UserInfo.Extra["payload/test"][0]).To(o.Equal(fmt.Sprintf("%s@payload.openshift.ioextra", username))) + }) + }) + }) + + g.Describe("[Skipped] with invalid specified UID or Extra claim mappings", func() { + g.It("should reject admission when UID claim expression is not compilable CEL", func() { + g.Skip("functionality not yet implemented") + /* + _, _, err := configureOIDCAuthentication(ctx, oc, func(o *configv1.OIDCProvider) { + o.ClaimMappings.UID = &configv1.TokenClaimOrExpressionMapping{ + Expression: "!@&*#^", + } + }) + o.Expect(err).To(o.HaveOccurred(), "should encounter an error configuring OIDC authentication") + */ + }) + + g.It("should reject admission when Extra claim expression is not compilable CEL", func() { + g.Skip("functionality not yet implemented") + /* + _, _, err := configureOIDCAuthentication(ctx, oc, func(o *configv1.OIDCProvider) { + o.ClaimMappings.Extra = []configv1.ExtraMapping{ + { + Key: "payload/test", + ValueExpression: "!@*&#^!@(*&^", + }, + } + }) + o.Expect(err).To(o.HaveOccurred(), "should encounter an error configuring OIDC authentication") + */ + }) + }) + }) + }) + + g.AfterAll(func() { + err := removeResources(ctx, cleanups...) + o.Expect(err).NotTo(o.HaveOccurred(), "should not encounter an error cleaning up keycloak resources") + + err = resetAuthentication(ctx, oc, originalAuth) + o.Expect(err).NotTo(o.HaveOccurred(), "should not encounter an error reverting authentication to original state") + + waitForRollout(ctx, oc) + }) +}) + +type removalFunc func(context.Context) error + +func removeResources(ctx context.Context, removalFuncs ...removalFunc) error { + errs := []error{} + + for _, removal := range removalFuncs { + if removal == nil { + continue + } + err := removal(ctx) + errs = append(errs, err) + } + + return errors.FilterOut(errors.NewAggregate(errs), apierrors.IsNotFound) +} + +func configureOIDCAuthentication(ctx context.Context, client *exutil.CLI, keycloakNS string, modifier func(*configv1.OIDCProvider)) (*configv1.Authentication, *configv1.Authentication, error) { + authConfig, err := client.AdminConfigClient().ConfigV1().Authentications().Get(ctx, "cluster", metav1.GetOptions{}) + if err != nil { + return nil, nil, fmt.Errorf("getting authentications.config.openshift.io/cluster: %w", err) + } + + original := authConfig.DeepCopy() + modified := authConfig.DeepCopy() + + oidcProvider, err := generateOIDCProvider(ctx, client, keycloakNS) + if err != nil { + return nil, nil, fmt.Errorf("generating OIDC provider: %w", err) + } + + if modifier != nil { + modifier(oidcProvider) + } + + modified.Spec.Type = configv1.AuthenticationTypeOIDC + modified.Spec.WebhookTokenAuthenticator = nil + modified.Spec.OIDCProviders = []configv1.OIDCProvider{*oidcProvider} + + modified, err = client.AdminConfigClient().ConfigV1().Authentications().Update(ctx, modified, metav1.UpdateOptions{}) + if err != nil { + return nil, nil, err + } + + return original, modified, nil +} + +func generateOIDCProvider(ctx context.Context, client *exutil.CLI, namespace string) (*configv1.OIDCProvider, error) { + idpName := "keycloak" + caBundle := "keycloak-ca" + audiences := []configv1.TokenAudience{ + "admin-cli", + } + usernameClaim := "email" + groupsClaim := "groups" + + idpUrl, err := admittedURLForRoute(ctx, client, keycloakResourceName, namespace) + if err != nil { + return nil, fmt.Errorf("getting issuer URL: %w", err) + } + + return &configv1.OIDCProvider{ + Name: idpName, + Issuer: configv1.TokenIssuer{ + URL: fmt.Sprintf("%s/realms/master", idpUrl), + CertificateAuthority: configv1.ConfigMapNameReference{ + Name: caBundle, + }, + Audiences: audiences, + }, + ClaimMappings: configv1.TokenClaimMappings{ + Username: configv1.UsernameClaimMapping{ + Claim: usernameClaim, + }, + Groups: configv1.PrefixedClaimMapping{ + TokenClaimMapping: configv1.TokenClaimMapping{ + Claim: groupsClaim, + }, + }, + }, + }, nil +} + +func admittedURLForRoute(ctx context.Context, client *exutil.CLI, routeName, namespace string) (string, error) { + var admittedURL string + + timeoutCtx, cancel := context.WithTimeout(ctx, 5*time.Minute) + defer cancel() + err := wait.PollUntilContextCancel(timeoutCtx, 1*time.Second, true, func(ctx context.Context) (bool, error) { + route, err := client.AdminRouteClient().RouteV1().Routes(namespace).Get(ctx, routeName, metav1.GetOptions{}) + if err != nil { + return false, nil + } + + for _, ingress := range route.Status.Ingress { + for _, condition := range ingress.Conditions { + if condition.Type == routev1.RouteAdmitted && condition.Status == corev1.ConditionTrue { + admittedURL = ingress.Host + return true, nil + } + } + } + + return false, fmt.Errorf("no admitted ingress for route %q", route.Name) + }) + return fmt.Sprintf("https://%s", admittedURL), err +} + +func resetAuthentication(ctx context.Context, client *exutil.CLI, original *configv1.Authentication) error { + if original == nil { + return nil + } + + timeoutCtx, cancel := context.WithDeadline(ctx, time.Now().Add(5*time.Minute)) + defer cancel() + cli := client.AdminConfigClient().ConfigV1().Authentications() + err := wait.PollUntilContextCancel(timeoutCtx, 10*time.Second, true, func(ctx context.Context) (done bool, err error) { + current, err := cli.Get(ctx, "cluster", metav1.GetOptions{}) + if err != nil { + return false, fmt.Errorf("getting the current authentications.config.openshift.io/cluster: %w", err) + } + + current.Spec = original.Spec + + _, err = cli.Update(ctx, current, metav1.UpdateOptions{}) + if err != nil { + return false, err + } + + return true, nil + }) + + return err +} + +func waitForRollout(ctx context.Context, client *exutil.CLI) { + kasCli := client.AdminOperatorClient().OperatorV1().KubeAPIServers() + + // First wait for KAS to flip to progressing + o.Eventually(func(gomega o.Gomega) { + kas, err := kasCli.Get(ctx, "cluster", metav1.GetOptions{}) + gomega.Expect(err).NotTo(o.HaveOccurred(), "should not encounter an error fetching the KAS") + + found := false + nipCond := operatorv1.OperatorCondition{} + for _, cond := range kas.Status.Conditions { + if cond.Type == condition.NodeInstallerProgressingConditionType { + found = true + nipCond = cond + break + } + } + + gomega.Expect(found).To(o.BeTrue(), "should have found the NodeInstallerProgressing condition") + gomega.Expect(nipCond.Status).To(o.Equal(operatorv1.ConditionTrue), "NodeInstallerProgressing condition should be True") + }).WithTimeout(5*time.Minute).WithPolling(10*time.Second).Should(o.Succeed(), "should eventually begin rolling out a new revision") + + // Then wait for it to flip back + o.Eventually(func(gomega o.Gomega) { + kas, err := kasCli.Get(ctx, "cluster", metav1.GetOptions{}) + gomega.Expect(err).NotTo(o.HaveOccurred(), "should not encounter an error fetching the KAS") + + found := false + nipCond := operatorv1.OperatorCondition{} + for _, cond := range kas.Status.Conditions { + if cond.Type == condition.NodeInstallerProgressingConditionType { + found = true + nipCond = cond + break + } + } + + gomega.Expect(found).To(o.BeTrue(), "should have found the NodeInstallerProgressing condition") + gomega.Expect(nipCond.Status).To(o.Equal(operatorv1.ConditionFalse), "NodeInstallerProgressing condition should be True") + }).WithTimeout(30*time.Minute).WithPolling(30*time.Second).Should(o.Succeed(), "should eventually rollout out a new revision successfully") +} diff --git a/test/extended/util/annotate/generated/zz_generated.annotations.go b/test/extended/util/annotate/generated/zz_generated.annotations.go index 9db297270b06..2ae5acba13e2 100644 --- a/test/extended/util/annotate/generated/zz_generated.annotations.go +++ b/test/extended/util/annotate/generated/zz_generated.annotations.go @@ -455,6 +455,34 @@ var Annotations = map[string]string{ "[sig-auth][Feature:UserAPI] users can manipulate groups [apigroup:user.openshift.io][apigroup:authorization.openshift.io][apigroup:project.openshift.io]": " [Suite:openshift/conformance/parallel]", + "[sig-auth][Suite:openshift/auth/external-oidc][Serial][Slow][Disruptive] [OCPFeatureGate:ExternalOIDCWithUIDAndExtraClaimMappings] external IdP is configured [Skipped] with invalid specified UID or Extra claim mappings should reject admission when Extra claim expression is not compilable CEL": "", + + "[sig-auth][Suite:openshift/auth/external-oidc][Serial][Slow][Disruptive] [OCPFeatureGate:ExternalOIDCWithUIDAndExtraClaimMappings] external IdP is configured [Skipped] with invalid specified UID or Extra claim mappings should reject admission when UID claim expression is not compilable CEL": "", + + "[sig-auth][Suite:openshift/auth/external-oidc][Serial][Slow][Disruptive] [OCPFeatureGate:ExternalOIDCWithUIDAndExtraClaimMappings] external IdP is configured with valid specified UID or Extra claim mappings checking cluster identity mapping should map Extra correctly": "", + + "[sig-auth][Suite:openshift/auth/external-oidc][Serial][Slow][Disruptive] [OCPFeatureGate:ExternalOIDCWithUIDAndExtraClaimMappings] external IdP is configured with valid specified UID or Extra claim mappings checking cluster identity mapping should map UID correctly": "", + + "[sig-auth][Suite:openshift/auth/external-oidc][Serial][Slow][Disruptive] [OCPFeatureGate:ExternalOIDCWithUIDAndExtraClaimMappings] external IdP is configured without specified UID or Extra claim mappings should default UID to the 'sub' claim in the access token from the IdP": "", + + "[sig-auth][Suite:openshift/auth/external-oidc][Serial][Slow][Disruptive] [OCPFeatureGate:ExternalOIDC] external IdP is configured [Skipped] should remove the OpenShift OAuth stack": "", + + "[sig-auth][Suite:openshift/auth/external-oidc][Serial][Slow][Disruptive] [OCPFeatureGate:ExternalOIDC] external IdP is configured should accept authentication via a certificate-based kubeconfig (break-glass)": "", + + "[sig-auth][Suite:openshift/auth/external-oidc][Serial][Slow][Disruptive] [OCPFeatureGate:ExternalOIDC] external IdP is configured should configure kube-apiserver": "", + + "[sig-auth][Suite:openshift/auth/external-oidc][Serial][Slow][Disruptive] [OCPFeatureGate:ExternalOIDC] external IdP is configured should map cluster identities correctly": "", + + "[sig-auth][Suite:openshift/auth/external-oidc][Serial][Slow][Disruptive] [OCPFeatureGate:ExternalOIDC] external IdP is configured should not accept tokens provided by the OAuth server": "", + + "[sig-auth][Suite:openshift/auth/external-oidc][Serial][Slow][Disruptive] [OCPFeatureGate:ExternalOIDC] reverting to IntegratedOAuth should accept tokens provided by the OpenShift OAuth server": "", + + "[sig-auth][Suite:openshift/auth/external-oidc][Serial][Slow][Disruptive] [OCPFeatureGate:ExternalOIDC] reverting to IntegratedOAuth should not accept tokens provided by an external IdP": "", + + "[sig-auth][Suite:openshift/auth/external-oidc][Serial][Slow][Disruptive] [OCPFeatureGate:ExternalOIDC] reverting to IntegratedOAuth should rollout configuration on the kube-apiserver successfully": "", + + "[sig-auth][Suite:openshift/auth/external-oidc][Serial][Slow][Disruptive] [OCPFeatureGate:ExternalOIDC] reverting to IntegratedOAuth should rollout the OpenShift OAuth stack": "", + "[sig-builds][Feature:Builds] Multi-stage image builds should succeed [apigroup:build.openshift.io]": " [Skipped:Disconnected] [Suite:openshift/conformance/parallel]", "[sig-builds][Feature:Builds] Optimized image builds should succeed [apigroup:build.openshift.io]": " [Skipped:Disconnected] [Suite:openshift/conformance/parallel]", diff --git a/test/extended/util/client.go b/test/extended/util/client.go index 90ed122f0aed..cfa9932c0830 100644 --- a/test/extended/util/client.go +++ b/test/extended/util/client.go @@ -860,6 +860,17 @@ func (c *CLI) NewPrometheusClient(ctx context.Context) prometheusv1.API { } func (c *CLI) UserConfig() *rest.Config { + if c.token != "" { + clientConfig, err := GetClientConfig(c.adminConfigPath) + if err != nil { + FatalErr(err) + } + + anon := rest.AnonymousClientConfig(clientConfig) + anon.BearerToken = c.token + return anon + } + clientConfig, err := GetClientConfig(c.configPath) if err != nil { FatalErr(err) diff --git a/vendor/github.com/openshift/library-go/pkg/operator/condition/condition.go b/vendor/github.com/openshift/library-go/pkg/operator/condition/condition.go new file mode 100644 index 000000000000..1a522609a596 --- /dev/null +++ b/vendor/github.com/openshift/library-go/pkg/operator/condition/condition.go @@ -0,0 +1,72 @@ +package condition + +const ( + // ManagementStateDegradedConditionType is true when the operator ManagementState is not "Managed".. + // Possible reasons are Unmanaged, Removed or Unknown. Any of these cases means the operator is not actively managing the operand. + // This condition is set to false when the ManagementState is set to back to "Managed". + ManagementStateDegradedConditionType = "ManagementStateDegraded" + + // UnsupportedConfigOverridesUpgradeableConditionType is true when operator unsupported config overrides is changed. + // When NoUnsupportedConfigOverrides reason is given it means there are no unsupported config overrides. + // When UnsupportedConfigOverridesSet reason is given it means the unsupported config overrides are set, which might impact the ability + // of operator to successfully upgrade its operand. + UnsupportedConfigOverridesUpgradeableConditionType = "UnsupportedConfigOverridesUpgradeable" + + // MonitoringResourceControllerDegradedConditionType is true when the operator is unable to create or reconcile the ServiceMonitor + // CR resource, which is required by monitoring operator to collect Prometheus data from the operator. When this condition is true and the ServiceMonitor + // is already created, it won't have impact on collecting metrics. However, if the ServiceMonitor was not created, the metrics won't be available for + // collection until this condition is set to false. + // The condition is set to false automatically when the operator successfully synchronize the ServiceMonitor resource. + MonitoringResourceControllerDegradedConditionType = "MonitoringResourceControllerDegraded" + + // BackingResourceControllerDegradedConditionType is true when the operator is unable to create or reconcile the resources needed + // to successfully run the installer pods (installer CRB and SA). If these were already created, this condition is not fatal, however if the resources + // were not created it means the installer pod creation will fail. + // This condition is set to false when the operator can successfully synchronize installer SA and CRB. + BackingResourceControllerDegradedConditionType = "BackingResourceControllerDegraded" + + // StaticPodsDegradedConditionType is true when the operator observe errors when installing the new revision static pods. + // This condition report Error reason when the pods are terminated or not ready or waiting during which the operand quality of service is degraded. + // This condition is set to False when the pods change state to running and are observed ready. + StaticPodsDegradedConditionType = "StaticPodsDegraded" + + // StaticPodsAvailableConditionType is true when the static pod is available on at least one node. + StaticPodsAvailableConditionType = "StaticPodsAvailable" + + // ConfigObservationDegradedConditionType is true when the operator failed to observe or process configuration change. + // This is not transient condition and normally a correction or manual intervention is required on the config custom resource. + ConfigObservationDegradedConditionType = "ConfigObservationDegraded" + + // ResourceSyncControllerDegradedConditionType is true when the operator failed to synchronize one or more secrets or config maps required + // to run the operand. Operand ability to provide service might be affected by this condition. + // This condition is set to false when the operator is able to create secrets and config maps. + ResourceSyncControllerDegradedConditionType = "ResourceSyncControllerDegraded" + + // CertRotationDegradedConditionTypeFmt is true when the operator failed to properly rotate one or more certificates required by the operand. + // The RotationError reason is given with message describing details of this failure. This condition can be fatal when ignored as the existing certificate(s) + // validity can expire and without rotating/renewing them manual recovery might be required to fix the cluster. + CertRotationDegradedConditionTypeFmt = "CertRotation_%s_Degraded" + + // InstallerControllerDegradedConditionType is true when the operator is not able to create new installer pods so the new revisions + // cannot be rolled out. This might happen when one or more required secrets or config maps does not exists. + // In case the missing secret or config map is available, this condition is automatically set to false. + InstallerControllerDegradedConditionType = "InstallerControllerDegraded" + + // NodeInstallerDegradedConditionType is true when the operator is not able to create new installer pods because there are no schedulable nodes + // available to run the installer pods. + // The AllNodesAtLatestRevision reason is set when all master nodes are updated to the latest revision. It is false when some masters are pending revision. + // ZeroNodesActive reason is set to True when no active master nodes are observed. Is set to False when there is at least one active master node. + NodeInstallerDegradedConditionType = "NodeInstallerDegraded" + + // NodeInstallerProgressingConditionType is true when the operator is moving nodes to a new revision. + NodeInstallerProgressingConditionType = "NodeInstallerProgressing" + + // RevisionControllerDegradedConditionType is true when the operator is not able to create new desired revision because an error occurred when + // the operator attempted to created required resource(s) (secrets, configmaps, ...). + // This condition mean no new revision will be created. + RevisionControllerDegradedConditionType = "RevisionControllerDegraded" + + // NodeControllerDegradedConditionType is true when the operator observed a master node that is not ready. + // Note that a node is not ready when its Condition.NodeReady wasn't set to true + NodeControllerDegradedConditionType = "NodeControllerDegraded" +) diff --git a/vendor/modules.txt b/vendor/modules.txt index 962322f68de0..1d5d29cdf80f 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -1153,6 +1153,7 @@ github.com/openshift/library-go/pkg/network/networkutils github.com/openshift/library-go/pkg/oauth/oauthdiscovery github.com/openshift/library-go/pkg/oauth/tokenrequest github.com/openshift/library-go/pkg/oauth/tokenrequest/challengehandlers +github.com/openshift/library-go/pkg/operator/condition github.com/openshift/library-go/pkg/operator/events github.com/openshift/library-go/pkg/operator/resource/resourceapply github.com/openshift/library-go/pkg/operator/resource/resourcehelper diff --git a/zz_generated.manifests/test-reporting.yaml b/zz_generated.manifests/test-reporting.yaml index 5f00758b1cf5..18ba1c5407cb 100644 --- a/zz_generated.manifests/test-reporting.yaml +++ b/zz_generated.manifests/test-reporting.yaml @@ -334,6 +334,57 @@ spec: tests: - testName: '[sig-arch][OCPFeatureGate:Example] should only run FeatureGated test when enabled' + - featureGate: ExternalOIDC + tests: + - testName: '[sig-auth][Suite:openshift/auth/external-oidc][Serial][Slow][Disruptive] + [OCPFeatureGate:ExternalOIDC] external IdP is configured [Skipped] should + remove the OpenShift OAuth stack' + - testName: '[sig-auth][Suite:openshift/auth/external-oidc][Serial][Slow][Disruptive] + [OCPFeatureGate:ExternalOIDC] external IdP is configured should accept authentication + via a certificate-based kubeconfig (break-glass)' + - testName: '[sig-auth][Suite:openshift/auth/external-oidc][Serial][Slow][Disruptive] + [OCPFeatureGate:ExternalOIDC] external IdP is configured should configure + kube-apiserver' + - testName: '[sig-auth][Suite:openshift/auth/external-oidc][Serial][Slow][Disruptive] + [OCPFeatureGate:ExternalOIDC] external IdP is configured should map cluster + identities correctly' + - testName: '[sig-auth][Suite:openshift/auth/external-oidc][Serial][Slow][Disruptive] + [OCPFeatureGate:ExternalOIDC] external IdP is configured should not accept + tokens provided by the OAuth server' + - testName: '[sig-auth][Suite:openshift/auth/external-oidc][Serial][Slow][Disruptive] + [OCPFeatureGate:ExternalOIDC] reverting to IntegratedOAuth should accept tokens + provided by the OpenShift OAuth server' + - testName: '[sig-auth][Suite:openshift/auth/external-oidc][Serial][Slow][Disruptive] + [OCPFeatureGate:ExternalOIDC] reverting to IntegratedOAuth should not accept + tokens provided by an external IdP' + - testName: '[sig-auth][Suite:openshift/auth/external-oidc][Serial][Slow][Disruptive] + [OCPFeatureGate:ExternalOIDC] reverting to IntegratedOAuth should rollout + configuration on the kube-apiserver successfully' + - testName: '[sig-auth][Suite:openshift/auth/external-oidc][Serial][Slow][Disruptive] + [OCPFeatureGate:ExternalOIDC] reverting to IntegratedOAuth should rollout + the OpenShift OAuth stack' + - featureGate: ExternalOIDCWithUIDAndExtraClaimMappings + tests: + - testName: '[sig-auth][Suite:openshift/auth/external-oidc][Serial][Slow][Disruptive] + [OCPFeatureGate:ExternalOIDCWithUIDAndExtraClaimMappings] external IdP is + configured [Skipped] with invalid specified UID or Extra claim mappings should + reject admission when Extra claim expression is not compilable CEL' + - testName: '[sig-auth][Suite:openshift/auth/external-oidc][Serial][Slow][Disruptive] + [OCPFeatureGate:ExternalOIDCWithUIDAndExtraClaimMappings] external IdP is + configured [Skipped] with invalid specified UID or Extra claim mappings should + reject admission when UID claim expression is not compilable CEL' + - testName: '[sig-auth][Suite:openshift/auth/external-oidc][Serial][Slow][Disruptive] + [OCPFeatureGate:ExternalOIDCWithUIDAndExtraClaimMappings] external IdP is + configured with valid specified UID or Extra claim mappings checking cluster + identity mapping should map Extra correctly' + - testName: '[sig-auth][Suite:openshift/auth/external-oidc][Serial][Slow][Disruptive] + [OCPFeatureGate:ExternalOIDCWithUIDAndExtraClaimMappings] external IdP is + configured with valid specified UID or Extra claim mappings checking cluster + identity mapping should map UID correctly' + - testName: '[sig-auth][Suite:openshift/auth/external-oidc][Serial][Slow][Disruptive] + [OCPFeatureGate:ExternalOIDCWithUIDAndExtraClaimMappings] external IdP is + configured without specified UID or Extra claim mappings should default UID + to the ''sub'' claim in the access token from the IdP' - featureGate: GatewayAPI tests: - testName: '[sig-network][OCPFeatureGate:GatewayAPI][Feature:Router][apigroup:gateway.networking.k8s.io]