diff --git a/actions/go.mod b/actions/go.mod index ee1ed4c6c4..07423e1459 100644 --- a/actions/go.mod +++ b/actions/go.mod @@ -59,7 +59,7 @@ replace ( require ( github.com/qase-tms/qase-go/qase-api-client v1.2.1 github.com/rancher/rancher/pkg/apis v0.0.0 - github.com/rancher/shepherd v0.0.0-20260610155525-6d926c75d49a + github.com/rancher/shepherd v0.0.0-20260616224945-d2cbef93a360 github.com/rancher/tfp-automation v0.0.0-20260701185620-64a4ef67d69a ) diff --git a/actions/go.sum b/actions/go.sum index a22442b39d..c26ab8a034 100644 --- a/actions/go.sum +++ b/actions/go.sum @@ -213,8 +213,8 @@ github.com/rancher/rancher v0.0.0-20260527150105-ae26ccbc3fed h1:G66Ag8VZXlA6Kiu github.com/rancher/rancher v0.0.0-20260527150105-ae26ccbc3fed/go.mod h1:wn9b239FRsjEulPTy5U1qTgT9vusgIc8Sxq0UJRdvhA= github.com/rancher/rancher/pkg/apis v0.0.0-20260527150105-ae26ccbc3fed h1:HoeCYnxcvP+p020CvCyNaJSwzH5I/nsQ6uRcbDbB17E= github.com/rancher/rancher/pkg/apis v0.0.0-20260527150105-ae26ccbc3fed/go.mod h1:TtM3SQiHWml7okiEMRndWKt1knQ/DeQC60Bzt6OBmrg= -github.com/rancher/shepherd v0.0.0-20260610155525-6d926c75d49a h1://lhZJ7E6znLttBO9b2+V/Xl51iw3bDspjiH8W5MxMk= -github.com/rancher/shepherd v0.0.0-20260610155525-6d926c75d49a/go.mod h1:OxexRIlEGlPaWS9YeFVfYD5ANH/8Yqb+hMyPuUiCp2o= +github.com/rancher/shepherd v0.0.0-20260616224945-d2cbef93a360 h1:wYxVY/UlFhBX7cC6p9t8cTY8EmJ4nYAKl1iNl/JmeKw= +github.com/rancher/shepherd v0.0.0-20260616224945-d2cbef93a360/go.mod h1:OxexRIlEGlPaWS9YeFVfYD5ANH/8Yqb+hMyPuUiCp2o= github.com/rancher/system-upgrade-controller/pkg/apis v0.0.0-20260519183600-f1362a3fe1a8 h1:7qMiCWOKZdhscekXzeIUpaGethXtj19ob6V+U91tAxI= github.com/rancher/system-upgrade-controller/pkg/apis v0.0.0-20260519183600-f1362a3fe1a8/go.mod h1:xDDFkXVPcCf+S6wQoIDwgIijQHcIaVdN0eQRG2GcFd4= github.com/rancher/tfp-automation v0.0.0-20260701185620-64a4ef67d69a h1:T1dleEon60KO4H6JN3QSRp7q+QHFKlt3bnrko4pmmcA= diff --git a/actions/kubeapi/projects/projects.go b/actions/kubeapi/projects/projects.go index c22264fa05..8f67bcb7da 100644 --- a/actions/kubeapi/projects/projects.go +++ b/actions/kubeapi/projects/projects.go @@ -67,6 +67,24 @@ func NewProjectTemplate(clusterID string) *v3.Project { return project } +// WaitForProjectBackingNamespace polls until the project controller sets Status.BackingNamespace, +// indicating the backing K8s namespace has been provisioned. +func WaitForProjectBackingNamespace(client *rancher.Client, clusterID, projectName string) (*v3.Project, error) { + var result *v3.Project + err := kwait.PollUntilContextTimeout(context.Background(), defaults.FiveSecondTimeout, defaults.OneMinuteTimeout, false, func(ctx context.Context) (bool, error) { + p, pollErr := extprojectapi.GetProjectByName(client, clusterID, projectName) + if pollErr != nil { + return false, pollErr + } + if p.Status.BackingNamespace != "" { + result = p + return true, nil + } + return false, nil + }) + return result, err +} + // WaitForProjectFinalizerToUpdate is a helper to wait for project finalizer to update to match the expected finalizer count func WaitForProjectFinalizerToUpdate(client *rancher.Client, projectName, projectNamespace string, finalizerCount int) error { err := kwait.PollUntilContextTimeout(context.Background(), defaults.FiveSecondTimeout, defaults.TenSecondTimeout, false, func(ctx context.Context) (bool, error) { diff --git a/actions/kubeapi/scim/scim.go b/actions/kubeapi/scim/scim.go new file mode 100644 index 0000000000..ce6779b11b --- /dev/null +++ b/actions/kubeapi/scim/scim.go @@ -0,0 +1,586 @@ +package scim + +import ( + "context" + "crypto/rand" + "encoding/hex" + "fmt" + "net/http" + "net/url" + "strconv" + "sync" + "time" + + "github.com/rancher/shepherd/clients/rancher" + scimclient "github.com/rancher/shepherd/clients/rancher/auth/scim" + extauthscim "github.com/rancher/shepherd/extensions/kubeapi/scim" + "github.com/rancher/shepherd/extensions/defaults" + extclusterapi "github.com/rancher/shepherd/extensions/kubeapi/cluster" + configmapsapi "github.com/rancher/shepherd/extensions/kubeapi/configmaps" + "github.com/rancher/shepherd/pkg/clientbase" + namegen "github.com/rancher/shepherd/pkg/namegenerator" + "github.com/rancher/shepherd/pkg/session" + extfeatures "github.com/rancher/shepherd/extensions/kubeapi/features" + "github.com/rancher/tests/actions/auth" + "github.com/sirupsen/logrus" + 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/labels" + kwait "k8s.io/apimachinery/pkg/util/wait" +) + +const ( + SCIMFeatureFlag = "scim" + SCIMSecretNamespace = "cattle-global-data" + SCIMSecretDataKey = "token" + + scimSecretKindLabel = "cattle.io/kind" + scimSecretKindValue = "scim-auth-token" + scimAuthProviderLabel = "authn.management.cattle.io/provider" +) + +var errNoSCIMTokenSecret = fmt.Errorf("no SCIM token secret found") + +// FetchSCIMBearerToken retrieves the SCIM bearer token for the given auth provider from the cattle-global-data namespace +func FetchSCIMBearerToken(client *rancher.Client, providerName string) (string, error) { + logrus.Infof("Fetching SCIM bearer token from %s by label %s=%s", + SCIMSecretNamespace, scimAuthProviderLabel, providerName) + selector := labels.SelectorFromSet(labels.Set{ + scimSecretKindLabel: scimSecretKindValue, + scimAuthProviderLabel: providerName, + }) + list, err := client.WranglerContext.Core.Secret().List( + SCIMSecretNamespace, + metav1.ListOptions{LabelSelector: selector.String()}, + ) + if err != nil { + return "", err + } + if len(list.Items) == 0 { + return "", errNoSCIMTokenSecret + } + newest := &list.Items[0] + for i := 1; i < len(list.Items); i++ { + if list.Items[i].CreationTimestamp.After(newest.CreationTimestamp.Time) { + newest = &list.Items[i] + } + } + if len(list.Items) > 1 { + logrus.Warnf("Multiple SCIM token secrets found for provider %s, using newest: %s", providerName, newest.Name) + } + token, ok := newest.Data[SCIMSecretDataKey] + if !ok || len(token) == 0 { + return "", fmt.Errorf("key %q not found or empty in secret %s/%s", + SCIMSecretDataKey, SCIMSecretNamespace, newest.Name) + } + logrus.Infof("Found SCIM token in secret %s/%s", SCIMSecretNamespace, newest.Name) + return string(token), nil +} + +// CreateSCIMTokenSecret generates a new random bearer token and stores it as a Kubernetes secret in the cattle-global-data namespace +func CreateSCIMTokenSecret(client *rancher.Client, providerName string) (string, error) { + b := make([]byte, 32) + if _, err := rand.Read(b); err != nil { + return "", err + } + token := hex.EncodeToString(b) + + secretName := fmt.Sprintf("scim-token-%s", providerName) + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: secretName, + Namespace: SCIMSecretNamespace, + Labels: map[string]string{ + scimSecretKindLabel: scimSecretKindValue, + scimAuthProviderLabel: providerName, + }, + }, + Data: map[string][]byte{ + SCIMSecretDataKey: []byte(token), + }, + } + + logrus.Infof("Creating SCIM token secret %s/%s", SCIMSecretNamespace, secretName) + _, err := client.WranglerContext.Core.Secret().Create(secret) + if err != nil { + return "", err + } + return token, nil +} + +// SetupSCIMClient ensures the SCIM feature flag is enabled and the auth provider +// is active, then fetches or creates a bearer token secret and returns a configured +// SCIM client ready to make requests against the given provider. +func SetupSCIMClient(client *rancher.Client, providerName string) (*scimclient.Client, error) { + enabled, err := extfeatures.IsFeatureEnabled(client, SCIMFeatureFlag) + if err != nil { + return nil, err + } + + if !enabled { + if err = extfeatures.UpdateFeatureFlag(client, SCIMFeatureFlag, true); err != nil { + return nil, err + } + } + + if err = auth.EnsureAuthProviderEnabled(client, providerName); err != nil { + return nil, err + } + + token, err := FetchSCIMBearerToken(client, providerName) + if err != nil { + if err != errNoSCIMTokenSecret { + return nil, err + } + logrus.Infof("No SCIM token secret found for provider %s, creating one", providerName) + token, err = CreateSCIMTokenSecret(client, providerName) + if err != nil { + return nil, err + } + } + + return scimclient.NewClient(&clientbase.ClientOpts{ + URL: fmt.Sprintf("https://%s", client.RancherConfig.Host), + TokenKey: token, + Insecure: true, + }, providerName), nil +} + +// NewSCIMClientWithToken returns a SCIM client configured with the given host, provider, and bearer token +func NewSCIMClientWithToken(host, providerName, token string) *scimclient.Client { + return scimclient.NewClient(&clientbase.ClientOpts{ + URL: fmt.Sprintf("https://%s", host), + TokenKey: token, + Insecure: true, + }, providerName) +} + +// WaitForSCIMResourceDeletion polls a SCIM GET endpoint until it returns 404. +func WaitForSCIMResourceDeletion(getFunc func() (int, error)) error { + return kwait.PollUntilContextTimeout( + context.Background(), + defaults.FiveSecondTimeout, + defaults.OneMinuteTimeout, + false, + func(ctx context.Context) (bool, error) { + status, err := getFunc() + if err != nil { + return false, nil + } + return status == http.StatusNotFound, nil + }, + ) +} + +// CreateSCIMUser builds a SCIM user with a random username and the given test-specific +// options, then delegates to shepherd's CreateUserWithTemplate which registers a session +// cleanup that deletes the user when the session ends. +func CreateSCIMUser(scimClient *scimclient.Client, ts *session.Session, externalID string, active bool) (string, string, error) { + name := namegen.AppendRandomString("scim-user") + user := scimclient.User{ + Schemas: []string{scimclient.SCIMSchemaUser}, + UserName: name, + } + if externalID != "" { + user.ExternalID = externalID + } + if active { + user.Active = scimclient.BoolPtr(true) + } + resp, err := extauthscim.CreateUserWithTemplate(scimClient, ts, user) + if err != nil { + return "", "", err + } + id, err := resp.IDFromBody() + if err != nil { + return "", "", err + } + return name, id, nil +} + +// CreateSCIMGroup builds a SCIM group with a random displayName and the given test-specific +// options, then delegates to shepherd's CreateGroupWithTemplate which registers a session +// cleanup that deletes the group when the session ends. +func CreateSCIMGroup(scimClient *scimclient.Client, ts *session.Session, externalID string) (string, string, error) { + groupName := namegen.AppendRandomString("scim-group") + group := scimclient.Group{ + Schemas: []string{scimclient.SCIMSchemaGroup}, + DisplayName: groupName, + } + if externalID != "" { + group.ExternalID = externalID + } + resp, err := extauthscim.CreateGroupWithTemplate(scimClient, ts, group) + if err != nil { + return "", "", err + } + groupID, err := resp.IDFromBody() + return groupName, groupID, err +} + +// WaitForGroupMemberCount polls GET /Groups/{id} until the members array has +// exactly expectedCount entries, allowing time for the wrangler cache to reflect writes. +func WaitForGroupMemberCount(scimClient *scimclient.Client, groupID string, expectedCount int) error { + return kwait.PollUntilContextTimeout( + context.Background(), + defaults.FiveSecondTimeout, + defaults.OneMinuteTimeout, + false, + func(ctx context.Context) (bool, error) { + resp, err := scimClient.Groups().ByID(groupID) + if err != nil || resp.StatusCode != http.StatusOK { + return false, nil + } + var body map[string]interface{} + if err := resp.DecodeJSON(&body); err != nil { + return false, nil + } + members, _ := body["members"].([]interface{}) + return len(members) == expectedCount, nil + }, + ) +} + +// ListSCIMUsersPage fetches a SCIM user page and returns the decoded JSON body. +func ListSCIMUsersPage(scimClient *scimclient.Client, start, count int) (map[string]interface{}, error) { + params := url.Values{} + params.Set("startIndex", fmt.Sprintf("%d", start)) + params.Set("count", fmt.Sprintf("%d", count)) + resp, err := scimClient.Users().List(params) + if err != nil { + return nil, err + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("expected 200, got %d: %s", resp.StatusCode, string(resp.Body)) + } + var body map[string]interface{} + if err := resp.DecodeJSON(&body); err != nil { + return nil, err + } + return body, nil +} + +// CheckStatus returns an error if the response status code does not match expected, +// including the response body in the error message for diagnostics. +func CheckStatus(resp *scimclient.Response, expected int, msg string) error { + if resp.StatusCode != expected { + return fmt.Errorf("%s: expected %d, got %d: %s", msg, expected, resp.StatusCode, string(resp.Body)) + } + return nil +} + +// BaselineSCIMConfigMap returns the canonical baseline ConfigMap data for SCIM tests: +// SCIM enabled, all other keys at defaults. Returns a fresh map each call. +func BaselineSCIMConfigMap() map[string]string { + return map[string]string{"enabled": "true"} +} + +// RateLimitSCIMConfigMap returns a SCIM ConfigMap with rate-limit keys set on top of +// the baseline (enabled=true). Use rps=0 to express disabled. +func RateLimitSCIMConfigMap(rps, burst int) map[string]string { + return map[string]string{ + "enabled": "true", + "rateLimitRequestsPerSecond": strconv.Itoa(rps), + "rateLimitBurst": strconv.Itoa(burst), + } +} + +// RestoreSCIMBaseline restores the SCIM ConfigMap to the canonical baseline state +// (enabled=true, no other keys) and waits for the endpoint to be fully ready +// for both reads and writes. Intended for use in test cleanup. +func RestoreSCIMBaseline(client *rancher.Client, scimClient *scimclient.Client, providerName string) error { + if err := SetSCIMConfigMap(client, providerName, BaselineSCIMConfigMap()); err != nil { + return err + } + if err := WaitForSCIMEndpointStatus(scimClient, http.StatusOK); err != nil { + return err + } + return WaitForSCIMCreateReady(scimClient) +} + +// WaitForSCIMCreateReady polls POST /Users until the SCIM handler accepts creates (201), +// confirming the write path is fully initialized after a ConfigMap change. A probe user +// is created and immediately deleted on success. +func WaitForSCIMCreateReady(scimClient *scimclient.Client) error { + return pollSCIMCreateReady(scimClient, defaults.OneMinuteTimeout) +} + +// WaitForRancherRestart waits for both the SCIM write path and the Kubernetes API proxy +// to be operational after a Rancher restart triggered by a feature flag change. +// Uses a 5-minute timeout for the SCIM probe to accommodate the longer restart cycle. +func WaitForRancherRestart(client *rancher.Client, scimClient *scimclient.Client) error { + if err := pollSCIMCreateReady(scimClient, defaults.FiveMinuteTimeout); err != nil { + return err + } + return pollWranglerAPIReady(client) +} + +func pollSCIMCreateReady(scimClient *scimclient.Client, timeout time.Duration) error { + // Require two consecutive 201 responses to guard against the handler + // briefly accepting a request during a transient state after a ConfigMap change. + consecutiveOK := 0 + return kwait.PollUntilContextTimeout( + context.Background(), + defaults.FiveSecondTimeout, + timeout, + false, + func(ctx context.Context) (bool, error) { + probe := scimclient.User{ + Schemas: []string{scimclient.SCIMSchemaUser}, + UserName: namegen.AppendRandomString("scim-probe"), + } + resp, err := scimClient.Users().Create(probe) + if err != nil || resp == nil { + consecutiveOK = 0 + return false, nil + } + if resp.StatusCode == http.StatusCreated { + if id, idErr := resp.IDFromBody(); idErr == nil && id != "" { + scimClient.Users().Delete(id) //nolint:errcheck + } + consecutiveOK++ + return consecutiveOK >= 2, nil + } + consecutiveOK = 0 + if resp.StatusCode >= 500 { + return false, nil + } + return false, fmt.Errorf("unexpected SCIM create probe status: %d", resp.StatusCode) + }, + ) +} + +func pollWranglerAPIReady(client *rancher.Client) error { + return kwait.PollUntilContextTimeout( + context.Background(), + defaults.FiveSecondTimeout, + defaults.TwoMinuteTimeout, + false, + func(ctx context.Context) (bool, error) { + _, err := client.WranglerContext.Core.Secret().List(SCIMSecretNamespace, metav1.ListOptions{}) + return err == nil, nil + }, + ) +} + +// SetSCIMConfigMap creates or updates the scim-config- ConfigMap in +// cattle-global-data with the given data. Idempotent: creates if missing, updates if present. +func SetSCIMConfigMap(client *rancher.Client, providerName string, data map[string]string) error { + name := fmt.Sprintf("scim-config-%s", providerName) + + existing, err := configmapsapi.GetConfigMapByName(client, extclusterapi.LocalCluster, SCIMSecretNamespace, name) + if err != nil { + if !apierrors.IsNotFound(err) { + return fmt.Errorf("failed to get configmap %s/%s: %w", SCIMSecretNamespace, name, err) + } + logrus.Infof("Creating SCIM ConfigMap %s/%s with %d keys", SCIMSecretNamespace, name, len(data)) + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: SCIMSecretNamespace, + }, + Data: data, + } + _, err = configmapsapi.CreateConfigMapWithTemplate(client, extclusterapi.LocalCluster, cm) + return err + } + + logrus.Infof("Updating SCIM ConfigMap %s/%s with %d keys", SCIMSecretNamespace, name, len(data)) + existing.Data = data + _, err = configmapsapi.UpdateConfigMap(client, extclusterapi.LocalCluster, SCIMSecretNamespace, existing) + return err +} + +// DeleteSCIMConfigMap removes the scim-config- ConfigMap from cattle-global-data. +// Returns nil if the ConfigMap is already absent. +func DeleteSCIMConfigMap(client *rancher.Client, providerName string) error { + name := fmt.Sprintf("scim-config-%s", providerName) + logrus.Infof("Deleting SCIM ConfigMap %s/%s", SCIMSecretNamespace, name) + err := configmapsapi.DeleteConfigMap(client, extclusterapi.LocalCluster, SCIMSecretNamespace, name, true) + if err != nil && apierrors.IsNotFound(err) { + return nil + } + return err +} + +// WaitForSCIMEndpointStatus polls GET /Users on the given SCIM client until the +// response status code matches expectedStatus or the timeout elapses. +func WaitForSCIMEndpointStatus(scimClient *scimclient.Client, expectedStatus int) error { + return kwait.PollUntilContextTimeout( + context.Background(), + defaults.FiveSecondTimeout, + defaults.FiveMinuteTimeout, + false, + func(ctx context.Context) (bool, error) { + resp, err := scimClient.Users().List(nil) + if err != nil { + return false, nil + } + return resp.StatusCode == expectedStatus, nil + }, + ) +} + +// BurstSCIMRequests fires n concurrent GET /Users requests and returns the per-request +// status codes. Order is not guaranteed. Used for rate-limit testing. +func BurstSCIMRequests(scimClient *scimclient.Client, n int) ([]int, error) { + if n <= 0 { + return nil, fmt.Errorf("BurstSCIMRequests: n must be > 0, got %d", n) + } + results := make([]int, n) + errs := make([]error, n) + var wg sync.WaitGroup + wg.Add(n) + for i := 0; i < n; i++ { + go func(idx int) { + defer wg.Done() + resp, err := scimClient.Users().List(nil) + if err != nil { + errs[idx] = err + return + } + results[idx] = resp.StatusCode + }(i) + } + wg.Wait() + for _, err := range errs { + if err != nil { + return results, err + } + } + return results, nil +} + +// ValidateSCIMErrorBody decodes the response body and asserts it matches the SCIM +// error schema (schemas, status as string, detail). expectedStatus is compared +// against the status field as a string. +func ValidateSCIMErrorBody(resp *scimclient.Response, expectedStatus int) error { + var body map[string]interface{} + if err := resp.DecodeJSON(&body); err != nil { + return fmt.Errorf("error body is not valid JSON: %w (body: %s)", err, string(resp.Body)) + } + schemas, ok := body["schemas"].([]interface{}) + if !ok || len(schemas) == 0 { + return fmt.Errorf("error body missing schemas array: %s", string(resp.Body)) + } + schemaURN, _ := schemas[0].(string) + if schemaURN != "urn:ietf:params:scim:api:messages:2.0:Error" { + return fmt.Errorf("error body schemas[0] = %q, want SCIM Error URN", schemaURN) + } + status, ok := body["status"].(string) + if !ok { + return fmt.Errorf("error body status field is not a string: %s", string(resp.Body)) + } + if status != strconv.Itoa(expectedStatus) { + return fmt.Errorf("error body status = %q, want %q", status, strconv.Itoa(expectedStatus)) + } + detail, _ := body["detail"].(string) + if detail == "" { + return fmt.Errorf("error body detail is empty: %s", string(resp.Body)) + } + return nil +} + +// GetRetryAfterSeconds reads the Retry-After header from a SCIM response and parses +// it as an integer number of seconds. Returns an error if the header is missing or non-numeric. +func GetRetryAfterSeconds(resp *scimclient.Response) (int, error) { + v := resp.Header.Get("Retry-After") + if v == "" { + return 0, fmt.Errorf("Retry-After header missing") + } + n, err := strconv.Atoi(v) + if err != nil { + return 0, fmt.Errorf("Retry-After header %q is not a valid integer: %w", v, err) + } + return n, nil +} + +// SetSCIMConfigMapAndWait applies the given SCIM ConfigMap data and polls GET /Users +// until the endpoint returns expectedStatus. +func SetSCIMConfigMapAndWait(client *rancher.Client, scimClient *scimclient.Client, providerName string, data map[string]string, expectedStatus int) error { + if err := SetSCIMConfigMap(client, providerName, data); err != nil { + return err + } + return WaitForSCIMEndpointStatus(scimClient, expectedStatus) +} + +// FindFirstThrottledResponse polls GET /Users up to maxAttempts times and returns the first +// response with status 429. Returns nil if no 429 was observed within maxAttempts. +func FindFirstThrottledResponse(scimClient *scimclient.Client, maxAttempts int) (*scimclient.Response, error) { + for i := 0; i < maxAttempts; i++ { + resp, err := scimClient.Users().List(nil) + if err != nil { + return nil, err + } + if resp.StatusCode == http.StatusTooManyRequests { + return resp, nil + } + } + return nil, nil +} + +// VerifySCIMThrottle reports whether GET /Users returned 429 within maxAttempts. +func VerifySCIMThrottle(scimClient *scimclient.Client, maxAttempts int) (bool, error) { + resp, err := FindFirstThrottledResponse(scimClient, maxAttempts) + if err != nil { + return false, err + } + return resp != nil, nil +} + +// DecodeJSONBody decodes a SCIM response body as a generic JSON map. +func DecodeJSONBody(resp *scimclient.Response) (map[string]interface{}, error) { + var body map[string]interface{} + if err := resp.DecodeJSON(&body); err != nil { + return nil, err + } + return body, nil +} + +// PatchUser issues a single-operation PATCH against /Users/{id} and returns the response. +func PatchUser(scimClient *scimclient.Client, userID, op, path string, value interface{}) (*scimclient.Response, error) { + return scimClient.Users().Patch(userID, scimclient.PatchOp{ + Schemas: []string{scimclient.SCIMSchemaPatchOp}, + Operations: []scimclient.Operation{ + {Op: op, Path: path, Value: value}, + }, + }) +} + +// PatchGroup issues a single-operation PATCH against /Groups/{id} and returns the response. +func PatchGroup(scimClient *scimclient.Client, groupID, op, path string, value interface{}) (*scimclient.Response, error) { + return scimClient.Groups().Patch(groupID, scimclient.PatchOp{ + Schemas: []string{scimclient.SCIMSchemaPatchOp}, + Operations: []scimclient.Operation{ + {Op: op, Path: path, Value: value}, + }, + }) +} + +// WaitForRancherUserName polls Management.User.ByID until the user's Name matches expectedName. +// Transient API errors are treated as "not yet" while the SCIM-created user propagates to the +// management API; the timeout is the authoritative failure signal. +func WaitForRancherUserName(client *rancher.Client, userID, expectedName string) error { + return kwait.PollUntilContextTimeout(context.Background(), defaults.FiveSecondTimeout, defaults.OneMinuteTimeout, false, func(ctx context.Context) (bool, error) { + mgmtUser, getErr := client.Management.User.ByID(userID) + if getErr != nil { + return false, nil + } + return mgmtUser.Name == expectedName, nil + }) +} + +// CountSCIMCodes counts 200 and 429 responses in a SCIM result slice. Used by +// rate-limit tests to assert mixed-outcome bursts. +func CountSCIMCodes(results []int) (ok, throttled int) { + for _, code := range results { + switch code { + case http.StatusOK: + ok++ + case http.StatusTooManyRequests: + throttled++ + } + } + return ok, throttled +} diff --git a/actions/scim/scim.go b/actions/scim/scim.go deleted file mode 100644 index b9ec935b54..0000000000 --- a/actions/scim/scim.go +++ /dev/null @@ -1,262 +0,0 @@ -package scim - -import ( - "context" - "crypto/rand" - "encoding/hex" - "fmt" - "net/http" - "net/url" - - "github.com/rancher/shepherd/clients/rancher" - scimclient "github.com/rancher/shepherd/clients/rancher/auth/scim" - "github.com/rancher/shepherd/extensions/defaults" - "github.com/rancher/shepherd/pkg/clientbase" - namegen "github.com/rancher/shepherd/pkg/namegenerator" - "github.com/rancher/tests/actions/auth" - "github.com/rancher/tests/actions/features" - "github.com/sirupsen/logrus" - corev1 "k8s.io/api/core/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/labels" - kwait "k8s.io/apimachinery/pkg/util/wait" -) - -const ( - SCIMFeatureFlag = "scim" - SCIMSecretNamespace = "cattle-global-data" - SCIMSecretDataKey = "token" - - scimSecretKindLabel = "cattle.io/kind" - scimSecretKindValue = "scim-auth-token" - scimAuthProviderLabel = "authn.management.cattle.io/provider" -) - -var errNoSCIMTokenSecret = fmt.Errorf("no SCIM token secret found") - -// FetchSCIMBearerToken retrieves the SCIM bearer token for the given auth provider from the cattle-global-data namespace -func FetchSCIMBearerToken(client *rancher.Client, providerName string) (string, error) { - logrus.Infof("Fetching SCIM bearer token from %s by label %s=%s", - SCIMSecretNamespace, scimAuthProviderLabel, providerName) - selector := labels.SelectorFromSet(labels.Set{ - scimSecretKindLabel: scimSecretKindValue, - scimAuthProviderLabel: providerName, - }) - list, err := client.WranglerContext.Core.Secret().List( - SCIMSecretNamespace, - metav1.ListOptions{LabelSelector: selector.String()}, - ) - if err != nil { - return "", err - } - if len(list.Items) == 0 { - return "", errNoSCIMTokenSecret - } - newest := &list.Items[0] - for i := 1; i < len(list.Items); i++ { - if list.Items[i].CreationTimestamp.After(newest.CreationTimestamp.Time) { - newest = &list.Items[i] - } - } - if len(list.Items) > 1 { - logrus.Warnf("Multiple SCIM token secrets found for provider %s, using newest: %s", providerName, newest.Name) - } - token, ok := newest.Data[SCIMSecretDataKey] - if !ok || len(token) == 0 { - return "", fmt.Errorf("key %q not found or empty in secret %s/%s", - SCIMSecretDataKey, SCIMSecretNamespace, newest.Name) - } - logrus.Infof("Found SCIM token in secret %s/%s", SCIMSecretNamespace, newest.Name) - return string(token), nil -} - -// CreateSCIMTokenSecret generates a new random bearer token and stores it as a Kubernetes secret in the cattle-global-data namespace -func CreateSCIMTokenSecret(client *rancher.Client, providerName string) (string, error) { - b := make([]byte, 32) - if _, err := rand.Read(b); err != nil { - return "", err - } - token := hex.EncodeToString(b) - - secretName := fmt.Sprintf("scim-token-%s", providerName) - secret := &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{ - Name: secretName, - Namespace: SCIMSecretNamespace, - Labels: map[string]string{ - scimSecretKindLabel: scimSecretKindValue, - scimAuthProviderLabel: providerName, - }, - }, - Data: map[string][]byte{ - SCIMSecretDataKey: []byte(token), - }, - } - - logrus.Infof("Creating SCIM token secret %s/%s", SCIMSecretNamespace, secretName) - _, err := client.WranglerContext.Core.Secret().Create(secret) - if err != nil { - return "", err - } - return token, nil -} - -// SetupSCIMClient ensures the SCIM feature flag is enabled and the auth provider -// is active, then fetches or creates a bearer token secret and returns a configured -// SCIM client ready to make requests against the given provider. -func SetupSCIMClient(client *rancher.Client, providerName string) (*scimclient.Client, error) { - enabled, err := features.IsEnabled(client, SCIMFeatureFlag) - if err != nil { - return nil, err - } - - if !enabled { - if err = features.UpdateFeatureFlag(client, SCIMFeatureFlag, true); err != nil { - return nil, err - } - } - - if err = auth.EnsureAuthProviderEnabled(client, providerName); err != nil { - return nil, err - } - - token, err := FetchSCIMBearerToken(client, providerName) - if err != nil { - if err != errNoSCIMTokenSecret { - return nil, err - } - logrus.Infof("No SCIM token secret found for provider %s, creating one", providerName) - token, err = CreateSCIMTokenSecret(client, providerName) - if err != nil { - return nil, err - } - } - - return scimclient.NewClient(&clientbase.ClientOpts{ - URL: fmt.Sprintf("https://%s", client.RancherConfig.Host), - TokenKey: token, - Insecure: true, - }, providerName), nil -} - -// NewSCIMClientWithToken returns a SCIM client configured with the given host, provider, and bearer token -func NewSCIMClientWithToken(host, providerName, token string) *scimclient.Client { - return scimclient.NewClient(&clientbase.ClientOpts{ - URL: fmt.Sprintf("https://%s", host), - TokenKey: token, - Insecure: true, - }, providerName) -} - -// WaitForSCIMResourceDeletion polls a SCIM GET endpoint until it returns 404. -func WaitForSCIMResourceDeletion(getFunc func() (int, error)) error { - return kwait.PollUntilContextTimeout( - context.Background(), - defaults.FiveSecondTimeout, - defaults.OneMinuteTimeout, - false, - func(ctx context.Context) (bool, error) { - status, err := getFunc() - if err != nil { - return false, nil - } - return status == http.StatusNotFound, nil - }, - ) -} - -// CreateSCIMUser creates a SCIM user with a random username and returns the username and ID. -func CreateSCIMUser(scimClient *scimclient.Client, externalID string, active bool) (string, string, error) { - userName := namegen.AppendRandomString("scim-user") - user := scimclient.User{ - Schemas: []string{scimclient.SCIMSchemaUser}, - UserName: userName, - } - if externalID != "" { - user.ExternalID = externalID - } - if active { - user.Active = scimclient.BoolPtr(true) - } - resp, err := scimClient.Users().Create(user) - if err != nil { - return "", "", err - } - if resp.StatusCode != http.StatusCreated { - return "", "", fmt.Errorf("expected 201, got %d: %s", resp.StatusCode, string(resp.Body)) - } - userID, err := resp.IDFromBody() - return userName, userID, err -} - -// CreateSCIMGroup creates a SCIM group with a random displayName and returns the displayName and ID. -func CreateSCIMGroup(scimClient *scimclient.Client, externalID string) (string, string, error) { - groupName := namegen.AppendRandomString("scim-group") - group := scimclient.Group{ - Schemas: []string{scimclient.SCIMSchemaGroup}, - DisplayName: groupName, - } - if externalID != "" { - group.ExternalID = externalID - } - resp, err := scimClient.Groups().Create(group) - if err != nil { - return "", "", err - } - if resp.StatusCode != http.StatusCreated { - return "", "", fmt.Errorf("expected 201, got %d: %s", resp.StatusCode, string(resp.Body)) - } - groupID, err := resp.IDFromBody() - return groupName, groupID, err -} - -// WaitForGroupMemberCount polls GET /Groups/{id} until the members array has -// exactly expectedCount entries, allowing time for the wrangler cache to reflect writes. -func WaitForGroupMemberCount(scimClient *scimclient.Client, groupID string, expectedCount int) error { - return kwait.PollUntilContextTimeout( - context.Background(), - defaults.FiveSecondTimeout, - defaults.OneMinuteTimeout, - false, - func(ctx context.Context) (bool, error) { - resp, err := scimClient.Groups().ByID(groupID) - if err != nil || resp.StatusCode != http.StatusOK { - return false, nil - } - var body map[string]interface{} - if err := resp.DecodeJSON(&body); err != nil { - return false, nil - } - members, _ := body["members"].([]interface{}) - return len(members) == expectedCount, nil - }, - ) -} - -// ListSCIMUsersPage fetches a SCIM user page and returns the decoded JSON body. -func ListSCIMUsersPage(scimClient *scimclient.Client, start, count int) (map[string]interface{}, error) { - params := url.Values{} - params.Set("startIndex", fmt.Sprintf("%d", start)) - params.Set("count", fmt.Sprintf("%d", count)) - resp, err := scimClient.Users().List(params) - if err != nil { - return nil, err - } - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("expected 200, got %d: %s", resp.StatusCode, string(resp.Body)) - } - var body map[string]interface{} - if err := resp.DecodeJSON(&body); err != nil { - return nil, err - } - return body, nil -} - -// CheckStatus returns an error if the response status code does not match expected, -// including the response body in the error message for diagnostics. -func CheckStatus(resp *scimclient.Response, expected int, msg string) error { - if resp.StatusCode != expected { - return fmt.Errorf("%s: expected %d, got %d: %s", msg, expected, resp.StatusCode, string(resp.Body)) - } - return nil -} diff --git a/go.mod b/go.mod index 1d5dd9427c..fd52bd24ab 100644 --- a/go.mod +++ b/go.mod @@ -70,7 +70,7 @@ require ( github.com/qase-tms/qase-go/pkg/qase-go v1.0.7 github.com/qase-tms/qase-go/qase-api-client v1.2.1 github.com/rancher/rancher v0.0.0-20260527150105-ae26ccbc3fed - github.com/rancher/shepherd v0.0.0-20260610155525-6d926c75d49a + github.com/rancher/shepherd v0.0.0-20260616224945-d2cbef93a360 github.com/rancher/tests/actions v0.0.0-20260610140123-a36d05641397 github.com/rancher/tests/interoperability v0.0.0 github.com/rancher/tfp-automation v0.0.0-20260701185620-64a4ef67d69a diff --git a/go.sum b/go.sum index 63809c8a50..a849c469e2 100644 --- a/go.sum +++ b/go.sum @@ -3697,8 +3697,8 @@ github.com/rancher/rancher v0.0.0-20260527150105-ae26ccbc3fed h1:G66Ag8VZXlA6Kiu github.com/rancher/rancher v0.0.0-20260527150105-ae26ccbc3fed/go.mod h1:wn9b239FRsjEulPTy5U1qTgT9vusgIc8Sxq0UJRdvhA= github.com/rancher/rancher/pkg/apis v0.0.0-20260527150105-ae26ccbc3fed h1:HoeCYnxcvP+p020CvCyNaJSwzH5I/nsQ6uRcbDbB17E= github.com/rancher/rancher/pkg/apis v0.0.0-20260527150105-ae26ccbc3fed/go.mod h1:TtM3SQiHWml7okiEMRndWKt1knQ/DeQC60Bzt6OBmrg= -github.com/rancher/shepherd v0.0.0-20260610155525-6d926c75d49a h1://lhZJ7E6znLttBO9b2+V/Xl51iw3bDspjiH8W5MxMk= -github.com/rancher/shepherd v0.0.0-20260610155525-6d926c75d49a/go.mod h1:OxexRIlEGlPaWS9YeFVfYD5ANH/8Yqb+hMyPuUiCp2o= +github.com/rancher/shepherd v0.0.0-20260616224945-d2cbef93a360 h1:wYxVY/UlFhBX7cC6p9t8cTY8EmJ4nYAKl1iNl/JmeKw= +github.com/rancher/shepherd v0.0.0-20260616224945-d2cbef93a360/go.mod h1:OxexRIlEGlPaWS9YeFVfYD5ANH/8Yqb+hMyPuUiCp2o= github.com/rancher/system-upgrade-controller/pkg/apis v0.0.0-20260519183600-f1362a3fe1a8 h1:7qMiCWOKZdhscekXzeIUpaGethXtj19ob6V+U91tAxI= github.com/rancher/system-upgrade-controller/pkg/apis v0.0.0-20260519183600-f1362a3fe1a8/go.mod h1:xDDFkXVPcCf+S6wQoIDwgIijQHcIaVdN0eQRG2GcFd4= github.com/rancher/tfp-automation v0.0.0-20260701185620-64a4ef67d69a h1:T1dleEon60KO4H6JN3QSRp7q+QHFKlt3bnrko4pmmcA= diff --git a/interoperability/go.mod b/interoperability/go.mod index a3c1369527..92f0e17e8c 100644 --- a/interoperability/go.mod +++ b/interoperability/go.mod @@ -67,7 +67,7 @@ require ( github.com/rancher/norman v0.9.6 github.com/rancher/qa-infra-automation v0.0.0-20260514152023-976143409dc3 github.com/rancher/rancher/pkg/apis v0.0.0 - github.com/rancher/shepherd v0.0.0-20260610155525-6d926c75d49a + github.com/rancher/shepherd v0.0.0-20260616224945-d2cbef93a360 github.com/rancher/tests/actions v0.0.0-20260610140123-a36d05641397 github.com/rancher/tfp-automation v0.0.0-20260701185620-64a4ef67d69a github.com/sirupsen/logrus v1.9.4 diff --git a/interoperability/go.sum b/interoperability/go.sum index 5b86062c03..1abf87ffa4 100644 --- a/interoperability/go.sum +++ b/interoperability/go.sum @@ -3651,8 +3651,8 @@ github.com/rancher/rancher v0.0.0-20260527150105-ae26ccbc3fed h1:G66Ag8VZXlA6Kiu github.com/rancher/rancher v0.0.0-20260527150105-ae26ccbc3fed/go.mod h1:wn9b239FRsjEulPTy5U1qTgT9vusgIc8Sxq0UJRdvhA= github.com/rancher/rancher/pkg/apis v0.0.0-20260527150105-ae26ccbc3fed h1:HoeCYnxcvP+p020CvCyNaJSwzH5I/nsQ6uRcbDbB17E= github.com/rancher/rancher/pkg/apis v0.0.0-20260527150105-ae26ccbc3fed/go.mod h1:TtM3SQiHWml7okiEMRndWKt1knQ/DeQC60Bzt6OBmrg= -github.com/rancher/shepherd v0.0.0-20260610155525-6d926c75d49a h1://lhZJ7E6znLttBO9b2+V/Xl51iw3bDspjiH8W5MxMk= -github.com/rancher/shepherd v0.0.0-20260610155525-6d926c75d49a/go.mod h1:OxexRIlEGlPaWS9YeFVfYD5ANH/8Yqb+hMyPuUiCp2o= +github.com/rancher/shepherd v0.0.0-20260616224945-d2cbef93a360 h1:wYxVY/UlFhBX7cC6p9t8cTY8EmJ4nYAKl1iNl/JmeKw= +github.com/rancher/shepherd v0.0.0-20260616224945-d2cbef93a360/go.mod h1:OxexRIlEGlPaWS9YeFVfYD5ANH/8Yqb+hMyPuUiCp2o= github.com/rancher/system-upgrade-controller/pkg/apis v0.0.0-20260519183600-f1362a3fe1a8 h1:7qMiCWOKZdhscekXzeIUpaGethXtj19ob6V+U91tAxI= github.com/rancher/system-upgrade-controller/pkg/apis v0.0.0-20260519183600-f1362a3fe1a8/go.mod h1:xDDFkXVPcCf+S6wQoIDwgIijQHcIaVdN0eQRG2GcFd4= github.com/rancher/tfp-automation v0.0.0-20260701185620-64a4ef67d69a h1:T1dleEon60KO4H6JN3QSRp7q+QHFKlt3bnrko4pmmcA= diff --git a/validation/auth/scim/scim_openldap_test.go b/validation/auth/scim/scim_openldap_test.go index 81633d3191..93e78adbdc 100644 --- a/validation/auth/scim/scim_openldap_test.go +++ b/validation/auth/scim/scim_openldap_test.go @@ -7,8 +7,11 @@ import ( "fmt" "net/http" "net/url" + "strings" "testing" + "time" + "github.com/rancher/norman/types" "github.com/rancher/shepherd/clients/rancher" v3 "github.com/rancher/shepherd/clients/rancher/generated/management/v3" "github.com/rancher/shepherd/extensions/clusters" @@ -19,14 +22,14 @@ import ( cattlev3 "github.com/rancher/rancher/pkg/apis/management.cattle.io/v3" scimclient "github.com/rancher/shepherd/clients/rancher/auth/scim" + extfeatures "github.com/rancher/shepherd/extensions/kubeapi/features" extrbacapi "github.com/rancher/shepherd/extensions/kubeapi/rbac" "github.com/rancher/shepherd/pkg/clientbase" authactions "github.com/rancher/tests/actions/auth" - "github.com/rancher/tests/actions/features" projectapi "github.com/rancher/tests/actions/kubeapi/projects" rbacapi "github.com/rancher/tests/actions/kubeapi/rbac" + scimactions "github.com/rancher/tests/actions/kubeapi/scim" rbac "github.com/rancher/tests/actions/rbac" - scimactions "github.com/rancher/tests/actions/scim" "github.com/sirupsen/logrus" "github.com/stretchr/testify/require" @@ -35,16 +38,12 @@ import ( kwait "k8s.io/apimachinery/pkg/util/wait" ) -const scimProvider = authactions.OpenLdap - type SCIMOpenLDAPTestSuite struct { suite.Suite session *session.Session client *rancher.Client cluster *v3.Cluster - adminUser *v3.User - authConfig *authactions.AuthConfig - scim *scimclient.Client + scimClient *scimclient.Client } func (s *SCIMOpenLDAPTestSuite) SetupSuite() { @@ -54,11 +53,6 @@ func (s *SCIMOpenLDAPTestSuite) SetupSuite() { require.NoError(s.T(), err, "Failed to create Rancher client") s.client = client - logrus.Info("Loading OpenLDAP auth configuration from config file") - s.authConfig = new(authactions.AuthConfig) - config.LoadConfig(authactions.OpenLdapAuthInput, s.authConfig) - require.NotNil(s.T(), s.authConfig, "Auth configuration is not provided") - logrus.Info("Getting cluster name from the config file") clusterName := client.RancherConfig.ClusterName require.NotEmpty(s.T(), clusterName, "Cluster name should be set") @@ -69,16 +63,16 @@ func (s *SCIMOpenLDAPTestSuite) SetupSuite() { s.cluster, err = s.client.Management.Cluster.ByID(clusterID) require.NoError(s.T(), err, "Failed to retrieve cluster by ID: %s", clusterID) - logrus.Info("Setting up admin user credentials for OpenLDAP authentication") - s.adminUser = &v3.User{ - Username: client.Auth.OLDAP.Config.Users.Admin.Username, - Password: client.Auth.OLDAP.Config.Users.Admin.Password, - } - logrus.Info("Setting up SCIM client for OpenLDAP provider") - scimClient, err := scimactions.SetupSCIMClient(s.client, scimProvider) + scimClient, err := scimactions.SetupSCIMClient(s.client, authactions.OpenLdap) require.NoError(s.T(), err, "Failed to setup SCIM client") - s.scim = scimClient + s.scimClient = scimClient + + logrus.Info("Ensuring baseline SCIM ConfigMap (enabled=true) for OpenLDAP provider") + err = scimactions.SetSCIMConfigMap(s.client, authactions.OpenLdap, map[string]string{"enabled": "true"}) + require.NoError(s.T(), err, "Failed to set baseline SCIM ConfigMap") + err = scimactions.WaitForSCIMEndpointStatus(s.scimClient, http.StatusOK) + require.NoError(s.T(), err, "SCIM endpoint should respond 200 after baseline ConfigMap set") } func (s *SCIMOpenLDAPTestSuite) TearDownSuite() { @@ -92,6 +86,7 @@ func (s *SCIMOpenLDAPTestSuite) TearDownSuite() { } } } + s.session.Cleanup() } func (s *SCIMOpenLDAPTestSuite) TestSCIMFeatureFlagEnabled() { @@ -100,11 +95,11 @@ func (s *SCIMOpenLDAPTestSuite) TestSCIMFeatureFlagEnabled() { logrus.Info("Verifying SCIM feature flag is enabled") - enabled, err := features.IsEnabled(s.client, scimactions.SCIMFeatureFlag) + enabled, err := extfeatures.IsFeatureEnabled(s.client, scimactions.SCIMFeatureFlag) require.NoError(s.T(), err, "Failed to check SCIM feature flag state") require.True(s.T(), enabled, "SCIM feature flag should be enabled") - resp, err := s.scim.Discovery().ServiceProviderConfig() + resp, err := s.scimClient.Discovery().ServiceProviderConfig() require.NoError(s.T(), err, "GET /ServiceProviderConfig should not error") require.NoError(s.T(), scimactions.CheckStatus(resp, http.StatusOK, "ServiceProviderConfig should return 200")) } @@ -113,9 +108,9 @@ func (s *SCIMOpenLDAPTestSuite) TestSCIMTokenSecretExistsInKubeAPI() { subSession := s.session.NewSession() defer subSession.Cleanup() - logrus.Infof("Verifying SCIM token secret exists in %s via label selector for provider %s", scimactions.SCIMSecretNamespace, scimProvider) + logrus.Infof("Verifying SCIM token secret exists in %s via label selector for provider %s", scimactions.SCIMSecretNamespace, authactions.OpenLdap) - token, err := scimactions.FetchSCIMBearerToken(s.client, scimProvider) + token, err := scimactions.FetchSCIMBearerToken(s.client, authactions.OpenLdap) require.NoError(s.T(), err, "SCIM token secret should exist in %s", scimactions.SCIMSecretNamespace) require.NotEmpty(s.T(), token, "SCIM bearer token should not be empty") } @@ -126,27 +121,18 @@ func (s *SCIMOpenLDAPTestSuite) TestSCIMZFeatureFlagDisableAndReenableEndpoint() logrus.Info("Disabling SCIM flag and verifying /Users returns 404") - err := features.UpdateFeatureFlag(s.client, scimactions.SCIMFeatureFlag, false) + err := extfeatures.UpdateFeatureFlag(s.client, scimactions.SCIMFeatureFlag, false) require.NoError(s.T(), err, "Should be able to disable SCIM feature flag") - resp, err := s.scim.Users().List(nil) - require.NoError(s.T(), err) - require.NoError(s.T(), scimactions.CheckStatus(resp, http.StatusNotFound, "GET /Users should return 404 when SCIM is disabled")) + err = scimactions.WaitForSCIMEndpointStatus(s.scimClient, http.StatusNotFound) + require.NoError(s.T(), err, "GET /Users should return 404 when SCIM is disabled") logrus.Info("Re-enabling SCIM flag after test") - err = features.UpdateFeatureFlag(s.client, scimactions.SCIMFeatureFlag, true) + err = extfeatures.UpdateFeatureFlag(s.client, scimactions.SCIMFeatureFlag, true) require.NoError(s.T(), err, "Should be able to re-enable SCIM feature flag") - logrus.Info("Waiting for SCIM endpoint to become available") - probe := scimactions.NewSCIMClientWithToken(s.client.RancherConfig.Host, scimProvider, "probe") - err = kwait.PollUntilContextTimeout(context.Background(), defaults.FiveSecondTimeout, defaults.FiveMinuteTimeout, false, func(ctx context.Context) (bool, error) { - resp, pollErr := probe.Discovery().ServiceProviderConfig() - if pollErr != nil { - return false, nil - } - return resp.StatusCode == http.StatusUnauthorized, nil - }) - require.NoError(s.T(), err, "SCIM endpoint should be available after re-enable") + logrus.Info("Waiting for Rancher to be fully operational after restart") + require.NoError(s.T(), scimactions.WaitForRancherRestart(s.client, s.scimClient), "Rancher should be fully operational after feature flag re-enable") } func (s *SCIMOpenLDAPTestSuite) TestSCIMServiceProviderConfig() { @@ -155,7 +141,7 @@ func (s *SCIMOpenLDAPTestSuite) TestSCIMServiceProviderConfig() { logrus.Info("Verifying GET /ServiceProviderConfig response") - resp, err := s.scim.Discovery().ServiceProviderConfig() + resp, err := s.scimClient.Discovery().ServiceProviderConfig() require.NoError(s.T(), err) require.NoError(s.T(), scimactions.CheckStatus(resp, http.StatusOK, "ServiceProviderConfig should return 200")) @@ -173,12 +159,11 @@ func (s *SCIMOpenLDAPTestSuite) TestSCIMResourceTypes() { logrus.Info("Verifying GET /ResourceTypes lists Users and Groups") - resp, err := s.scim.Discovery().ResourceTypes() + resp, err := s.scimClient.Discovery().ResourceTypes() require.NoError(s.T(), err) require.NoError(s.T(), scimactions.CheckStatus(resp, http.StatusOK, "ResourceTypes should return 200")) - var body map[string]interface{} - err = resp.DecodeJSON(&body) + body, err := scimactions.DecodeJSONBody(resp) require.NoError(s.T(), err) resourceTypes, ok := body["Resources"].([]interface{}) @@ -196,7 +181,7 @@ func (s *SCIMOpenLDAPTestSuite) TestSCIMInvalidTokenReturns401() { URL: fmt.Sprintf("https://%s", s.client.RancherConfig.Host), TokenKey: "invalid-token", Insecure: true, - }, scimProvider) + }, authactions.OpenLdap) resp, err := badClient.Users().List(nil) require.NoError(s.T(), err) @@ -209,12 +194,11 @@ func (s *SCIMOpenLDAPTestSuite) TestSCIMListUsers() { logrus.Info("Verifying GET /Users returns 200 with valid ListResponse") - resp, err := s.scim.Users().List(nil) + resp, err := s.scimClient.Users().List(nil) require.NoError(s.T(), err) require.NoError(s.T(), scimactions.CheckStatus(resp, http.StatusOK, "GET /Users should return 200")) - var body map[string]interface{} - err = resp.DecodeJSON(&body) + body, err := scimactions.DecodeJSONBody(resp) require.NoError(s.T(), err) require.Contains(s.T(), body, "totalResults", "ListResponse should have totalResults") require.Contains(s.T(), body, "Resources", "ListResponse should have Resources array") @@ -226,16 +210,14 @@ func (s *SCIMOpenLDAPTestSuite) TestSCIMCreateAndGetUser() { logrus.Info("Creating SCIM user with externalID") - userName, userID, err := scimactions.CreateSCIMUser(s.scim, "ext-"+namegen.AppendRandomString("id"), true) + userName, userID, err := scimactions.CreateSCIMUser(s.scimClient, subSession, "ext-"+namegen.AppendRandomString("id"), true) require.NoError(s.T(), err) - defer s.scim.Users().Delete(userID) - getResp, err := s.scim.Users().ByID(userID) + getResp, err := s.scimClient.Users().ByID(userID) require.NoError(s.T(), err) require.NoError(s.T(), scimactions.CheckStatus(getResp, http.StatusOK, "GET /Users/{id} should return 200")) - var body map[string]interface{} - err = getResp.DecodeJSON(&body) + body, err := scimactions.DecodeJSONBody(getResp) require.NoError(s.T(), err) require.Equal(s.T(), userName, body["userName"], "userName in response should match created value") require.Equal(s.T(), userID, body["id"], "id in response should match") @@ -247,11 +229,10 @@ func (s *SCIMOpenLDAPTestSuite) TestSCIMCreateDuplicateUserReturns409() { logrus.Info("Verifying duplicate userName returns 409") - userName, userID, err := scimactions.CreateSCIMUser(s.scim, "", true) + userName, _, err := scimactions.CreateSCIMUser(s.scimClient, subSession, "", true) require.NoError(s.T(), err) - defer s.scim.Users().Delete(userID) - resp2, err := s.scim.Users().Create(scimclient.User{ + resp2, err := s.scimClient.Users().Create(scimclient.User{ Schemas: []string{scimclient.SCIMSchemaUser}, UserName: userName, }) @@ -265,19 +246,17 @@ func (s *SCIMOpenLDAPTestSuite) TestSCIMFilterUserByUserName() { logrus.Info("Verifying filter by userName") - userName, userID, err := scimactions.CreateSCIMUser(s.scim, "", true) + userName, _, err := scimactions.CreateSCIMUser(s.scimClient, subSession, "", true) require.NoError(s.T(), err) - defer s.scim.Users().Delete(userID) params := url.Values{} params.Set("filter", fmt.Sprintf("userName eq %q", userName)) - filterResp, err := s.scim.Users().List(params) + filterResp, err := s.scimClient.Users().List(params) require.NoError(s.T(), err) require.NoError(s.T(), scimactions.CheckStatus(filterResp, http.StatusOK, "Filtered GET /Users should return 200")) - var body map[string]interface{} - err = filterResp.DecodeJSON(&body) + body, err := scimactions.DecodeJSONBody(filterResp) require.NoError(s.T(), err) userResources, _ := body["Resources"].([]interface{}) @@ -293,16 +272,14 @@ func (s *SCIMOpenLDAPTestSuite) TestSCIMPatchDeactivateUser() { logrus.Info("Verifying PATCH deactivate for user") - _, userID, err := scimactions.CreateSCIMUser(s.scim, "", false) + _, userID, err := scimactions.CreateSCIMUser(s.scimClient, subSession, "", false) require.NoError(s.T(), err) - defer s.scim.Users().Delete(userID) - - getResp, err := s.scim.Users().ByID(userID) + getResp, err := s.scimClient.Users().ByID(userID) require.NoError(s.T(), err) require.NoError(s.T(), scimactions.CheckStatus(getResp, http.StatusOK, "GET /Users/{id} should return 200 before PATCH")) - patchResp, err := s.scim.Users().Patch(userID, scimclient.PatchOp{ + patchResp, err := s.scimClient.Users().Patch(userID, scimclient.PatchOp{ Schemas: []string{scimclient.SCIMSchemaPatchOp}, Operations: []scimclient.Operation{ {Op: "replace", Path: "active", Value: false}, @@ -311,8 +288,7 @@ func (s *SCIMOpenLDAPTestSuite) TestSCIMPatchDeactivateUser() { require.NoError(s.T(), err) require.NoError(s.T(), scimactions.CheckStatus(patchResp, http.StatusOK, "PATCH active=false should return 200")) - var body map[string]interface{} - err = patchResp.DecodeJSON(&body) + body, err := scimactions.DecodeJSONBody(patchResp) require.NoError(s.T(), err) require.Equal(s.T(), false, body["active"], "active should be false after deactivation") } @@ -323,12 +299,10 @@ func (s *SCIMOpenLDAPTestSuite) TestSCIMPatchReactivateUser() { logrus.Info("Verifying PATCH reactivate for user") - _, userID, err := scimactions.CreateSCIMUser(s.scim, "", false) + _, userID, err := scimactions.CreateSCIMUser(s.scimClient, subSession, "", false) require.NoError(s.T(), err) - defer s.scim.Users().Delete(userID) - - deactivateResp, err := s.scim.Users().Patch(userID, scimclient.PatchOp{ + deactivateResp, err := s.scimClient.Users().Patch(userID, scimclient.PatchOp{ Schemas: []string{scimclient.SCIMSchemaPatchOp}, Operations: []scimclient.Operation{ {Op: "replace", Path: "active", Value: false}, @@ -337,7 +311,7 @@ func (s *SCIMOpenLDAPTestSuite) TestSCIMPatchReactivateUser() { require.NoError(s.T(), err) require.NoError(s.T(), scimactions.CheckStatus(deactivateResp, http.StatusOK, "PATCH active=false should return 200")) - patchResp, err := s.scim.Users().Patch(userID, scimclient.PatchOp{ + patchResp, err := s.scimClient.Users().Patch(userID, scimclient.PatchOp{ Schemas: []string{scimclient.SCIMSchemaPatchOp}, Operations: []scimclient.Operation{ {Op: "replace", Path: "active", Value: true}, @@ -346,8 +320,7 @@ func (s *SCIMOpenLDAPTestSuite) TestSCIMPatchReactivateUser() { require.NoError(s.T(), err) require.NoError(s.T(), scimactions.CheckStatus(patchResp, http.StatusOK, "PATCH active=true should return 200")) - var body map[string]interface{} - err = patchResp.DecodeJSON(&body) + body, err := scimactions.DecodeJSONBody(patchResp) require.NoError(s.T(), err) require.Equal(s.T(), true, body["active"], "active should be true after reactivation") } @@ -358,15 +331,15 @@ func (s *SCIMOpenLDAPTestSuite) TestSCIMDeleteUser() { logrus.Info("Verifying DELETE /Users/{id}") - _, userID, err := scimactions.CreateSCIMUser(s.scim, "", true) + _, userID, err := scimactions.CreateSCIMUser(s.scimClient, subSession, "", true) require.NoError(s.T(), err) - deleteResp, err := s.scim.Users().Delete(userID) + deleteResp, err := s.scimClient.Users().Delete(userID) require.NoError(s.T(), err) require.NoError(s.T(), scimactions.CheckStatus(deleteResp, http.StatusNoContent, "DELETE /Users/{id} should return 204")) err = scimactions.WaitForSCIMResourceDeletion(func() (int, error) { - resp, pollErr := s.scim.Users().ByID(userID) + resp, pollErr := s.scimClient.Users().ByID(userID) if pollErr != nil { return 0, nil } @@ -381,7 +354,7 @@ func (s *SCIMOpenLDAPTestSuite) TestSCIMGetNonExistentUserReturns404() { logrus.Info("Verifying GET /Users/nonexistent-id returns 404") - resp, err := s.scim.Users().ByID("nonexistent-id-99999") + resp, err := s.scimClient.Users().ByID("nonexistent-id-99999") require.NoError(s.T(), err) require.NoError(s.T(), scimactions.CheckStatus(resp, http.StatusNotFound, "GET non-existent user should return 404")) } @@ -396,12 +369,11 @@ func (s *SCIMOpenLDAPTestSuite) TestSCIMUserPagination() { params.Set("startIndex", "1") params.Set("count", "2") - resp, err := s.scim.Users().List(params) + resp, err := s.scimClient.Users().List(params) require.NoError(s.T(), err) require.NoError(s.T(), scimactions.CheckStatus(resp, http.StatusOK, "Paginated GET /Users should return 200")) - var body map[string]interface{} - err = resp.DecodeJSON(&body) + body, err := scimactions.DecodeJSONBody(resp) require.NoError(s.T(), err) require.Contains(s.T(), body, "totalResults", "Paginated response should include totalResults") require.Contains(s.T(), body, "startIndex", "Paginated response should echo startIndex") @@ -418,12 +390,11 @@ func (s *SCIMOpenLDAPTestSuite) TestSCIMOutOfBoundsStartIndexReturnsEmpty() { params.Set("startIndex", "9999") params.Set("count", "5") - resp, err := s.scim.Users().List(params) + resp, err := s.scimClient.Users().List(params) require.NoError(s.T(), err) require.NoError(s.T(), scimactions.CheckStatus(resp, http.StatusOK, "Out-of-bounds startIndex should return 200")) - var body map[string]interface{} - err = resp.DecodeJSON(&body) + body, err := scimactions.DecodeJSONBody(resp) require.NoError(s.T(), err) userResources, _ := body["Resources"].([]interface{}) @@ -436,16 +407,14 @@ func (s *SCIMOpenLDAPTestSuite) TestSCIMCreateAndGetGroup() { logrus.Info("Creating SCIM group") - groupName, groupID, err := scimactions.CreateSCIMGroup(s.scim, "") + groupName, groupID, err := scimactions.CreateSCIMGroup(s.scimClient, subSession, "") require.NoError(s.T(), err) - defer s.scim.Groups().Delete(groupID) - getResp, err := s.scim.Groups().ByID(groupID) + getResp, err := s.scimClient.Groups().ByID(groupID) require.NoError(s.T(), err) require.NoError(s.T(), scimactions.CheckStatus(getResp, http.StatusOK, "GET /Groups/{id} should return 200")) - var body map[string]interface{} - err = getResp.DecodeJSON(&body) + body, err := scimactions.DecodeJSONBody(getResp) require.NoError(s.T(), err) require.Equal(s.T(), groupName, body["displayName"], "displayName should match") } @@ -456,11 +425,10 @@ func (s *SCIMOpenLDAPTestSuite) TestSCIMCreateDuplicateGroupReturns409() { logrus.Info("Verifying duplicate group displayName returns 409") - groupName, groupID, err := scimactions.CreateSCIMGroup(s.scim, "") + groupName, _, err := scimactions.CreateSCIMGroup(s.scimClient, subSession, "") require.NoError(s.T(), err) - defer s.scim.Groups().Delete(groupID) - resp2, err := s.scim.Groups().Create(scimclient.Group{ + resp2, err := s.scimClient.Groups().Create(scimclient.Group{ Schemas: []string{scimclient.SCIMSchemaGroup}, DisplayName: groupName, }) @@ -474,15 +442,13 @@ func (s *SCIMOpenLDAPTestSuite) TestSCIMPatchAddMemberToGroup() { logrus.Info("Verifying PATCH add member to group") - _, userID, err := scimactions.CreateSCIMUser(s.scim, "", true) + _, userID, err := scimactions.CreateSCIMUser(s.scimClient, subSession, "", true) require.NoError(s.T(), err) - defer s.scim.Users().Delete(userID) - _, groupID, err := scimactions.CreateSCIMGroup(s.scim, "") + _, groupID, err := scimactions.CreateSCIMGroup(s.scimClient, subSession, "") require.NoError(s.T(), err) - defer s.scim.Groups().Delete(groupID) - patchResp, err := s.scim.Groups().Patch(groupID, scimclient.PatchOp{ + patchResp, err := s.scimClient.Groups().Patch(groupID, scimclient.PatchOp{ Schemas: []string{scimclient.SCIMSchemaPatchOp}, Operations: []scimclient.Operation{ {Op: "add", Path: "members", Value: []scimclient.Member{{Value: userID}}}, @@ -491,7 +457,7 @@ func (s *SCIMOpenLDAPTestSuite) TestSCIMPatchAddMemberToGroup() { require.NoError(s.T(), err) require.NoError(s.T(), scimactions.CheckStatus(patchResp, http.StatusOK, "PATCH add member should return 200")) - err = scimactions.WaitForGroupMemberCount(s.scim, groupID, 1) + err = scimactions.WaitForGroupMemberCount(s.scimClient, groupID, 1) require.NoError(s.T(), err, "Timed out waiting for group member to appear via GET") } @@ -501,15 +467,13 @@ func (s *SCIMOpenLDAPTestSuite) TestSCIMPatchRemoveMemberFromGroup() { logrus.Info("Verifying PATCH remove member from group") - _, userID, err := scimactions.CreateSCIMUser(s.scim, "", true) + _, userID, err := scimactions.CreateSCIMUser(s.scimClient, subSession, "", true) require.NoError(s.T(), err) - defer s.scim.Users().Delete(userID) - _, groupID, err := scimactions.CreateSCIMGroup(s.scim, "") + _, groupID, err := scimactions.CreateSCIMGroup(s.scimClient, subSession, "") require.NoError(s.T(), err) - defer s.scim.Groups().Delete(groupID) - addResp, err := s.scim.Groups().Patch(groupID, scimclient.PatchOp{ + addResp, err := s.scimClient.Groups().Patch(groupID, scimclient.PatchOp{ Schemas: []string{scimclient.SCIMSchemaPatchOp}, Operations: []scimclient.Operation{ {Op: "add", Path: "members", Value: []scimclient.Member{{Value: userID}}}, @@ -518,7 +482,7 @@ func (s *SCIMOpenLDAPTestSuite) TestSCIMPatchRemoveMemberFromGroup() { require.NoError(s.T(), err) require.NoError(s.T(), scimactions.CheckStatus(addResp, http.StatusOK, "PATCH add member should return 200")) - patchResp, err := s.scim.Groups().Patch(groupID, scimclient.PatchOp{ + patchResp, err := s.scimClient.Groups().Patch(groupID, scimclient.PatchOp{ Schemas: []string{scimclient.SCIMSchemaPatchOp}, Operations: []scimclient.Operation{ {Op: "remove", Path: fmt.Sprintf("members[value eq %q]", userID)}, @@ -527,7 +491,7 @@ func (s *SCIMOpenLDAPTestSuite) TestSCIMPatchRemoveMemberFromGroup() { require.NoError(s.T(), err) require.NoError(s.T(), scimactions.CheckStatus(patchResp, http.StatusOK, "PATCH remove member should return 200")) - err = scimactions.WaitForGroupMemberCount(s.scim, groupID, 0) + err = scimactions.WaitForGroupMemberCount(s.scimClient, groupID, 0) require.NoError(s.T(), err, "Timed out waiting for group member to be removed via GET") } @@ -540,12 +504,11 @@ func (s *SCIMOpenLDAPTestSuite) TestSCIMGroupListExcludeMembersAttribute() { params := url.Values{} params.Set("excludedAttributes", "members") - resp, err := s.scim.Groups().List(params) + resp, err := s.scimClient.Groups().List(params) require.NoError(s.T(), err) require.NoError(s.T(), scimactions.CheckStatus(resp, http.StatusOK, "GET /Groups?excludedAttributes=members should return 200")) - var body map[string]interface{} - err = resp.DecodeJSON(&body) + body, err := scimactions.DecodeJSONBody(resp) require.NoError(s.T(), err) groupResources, _ := body["Resources"].([]interface{}) @@ -565,12 +528,11 @@ func (s *SCIMOpenLDAPTestSuite) TestSCIMCannotViewDefaultAdmin() { params := url.Values{} params.Set("filter", `username eq "admin"`) - resp, err := s.scim.Users().List(params) + resp, err := s.scimClient.Users().List(params) require.NoError(s.T(), err) require.NoError(s.T(), scimactions.CheckStatus(resp, http.StatusOK, "Filter request should return 200")) - var body map[string]interface{} - err = resp.DecodeJSON(&body) + body, err := scimactions.DecodeJSONBody(resp) require.NoError(s.T(), err) userResources, _ := body["Resources"].([]interface{}) @@ -583,7 +545,7 @@ func (s *SCIMOpenLDAPTestSuite) TestSCIMPatchNonExistentUserReturns404() { logrus.Info("Verifying PATCH active=false on non-existent SCIM user returns 404") - patchResp, err := s.scim.Users().Patch("nonexistent-admin-id", scimclient.PatchOp{ + patchResp, err := s.scimClient.Users().Patch("nonexistent-admin-id", scimclient.PatchOp{ Schemas: []string{scimclient.SCIMSchemaPatchOp}, Operations: []scimclient.Operation{ {Op: "replace", Path: "active", Value: false}, @@ -594,7 +556,11 @@ func (s *SCIMOpenLDAPTestSuite) TestSCIMPatchNonExistentUserReturns404() { } func (s *SCIMOpenLDAPTestSuite) TestSCIMOpenLDAPAuthUnaffectedBySCIM() { - subSession, authAdmin, err := authactions.SetupAuthenticatedSession(s.client, s.session, s.adminUser, authactions.OpenLdap) + adminUser := &v3.User{ + Username: s.client.Auth.OLDAP.Config.Users.Admin.Username, + Password: s.client.Auth.OLDAP.Config.Users.Admin.Password, + } + subSession, authAdmin, err := authactions.SetupAuthenticatedSession(s.client, s.session, adminUser, authactions.OpenLdap) require.NoError(s.T(), err, "OpenLDAP auth session should succeed while SCIM is enabled") defer subSession.Cleanup() @@ -620,7 +586,7 @@ func (s *SCIMOpenLDAPTestSuite) TestSCIMTokenSecretStillPresentAfterAuthTests() logrus.Info("Verifying SCIM token secret is still present via kubeapi after auth regression tests") - token, err := scimactions.FetchSCIMBearerToken(s.client, scimProvider) + token, err := scimactions.FetchSCIMBearerToken(s.client, authactions.OpenLdap) require.NoError(s.T(), err, "SCIM secret should still be present after auth regression tests") require.NotEmpty(s.T(), token, "SCIM bearer token should still be non-empty") } @@ -629,27 +595,26 @@ func (s *SCIMOpenLDAPTestSuite) TestSCIMAuthConfigEnabledViaSteve() { subSession := s.session.NewSession() defer subSession.Cleanup() - logrus.Info("Verifying OpenLDAP authconfig reports enabled=true via Steve") + logrus.Info("Verifying OpenLDAP authconfig reports enabled=true") - authConfigResource, err := s.client.Steve.SteveType("management.cattle.io.authconfig").ByID(authactions.OpenLdap) - require.NoError(s.T(), err, "Should be able to GET openldap authconfig via Steve") - require.NotNil(s.T(), authConfigResource) - - enabled, _ := authConfigResource.JSONResp["enabled"].(bool) - require.True(s.T(), enabled, "OpenLDAP authconfig should report enabled=true via Steve") + authConfig, err := s.client.Management.AuthConfig.ByID(authactions.OpenLdap) + require.NoError(s.T(), err, "Should be able to GET openldap authconfig") + require.True(s.T(), authConfig.Enabled, "OpenLDAP authconfig should report enabled=true") } func (s *SCIMOpenLDAPTestSuite) TestSCIMAuthUsersAsClusterMembers() { subSession := s.session.NewSession() defer subSession.Cleanup() - require.NotEmpty(s.T(), s.authConfig.Users, "At least one auth user must be configured in cattle-config.yaml") + authConfig := new(authactions.AuthConfig) + config.LoadConfig(authactions.OpenLdapAuthInput, authConfig) + require.NotEmpty(s.T(), authConfig.Users, "At least one auth user must be configured in cattle-config.yaml") - for _, authUser := range s.authConfig.Users { + for _, authUser := range authConfig.Users { logrus.Infof("Creating cluster-member CRTB for auth user %s", authUser.Username) userV3 := &v3.User{Username: authUser.Username, Password: authUser.Password} - authAdmin, err := authactions.LoginAsAuthUser(s.client, userV3, scimProvider) + authAdmin, err := authactions.LoginAsAuthUser(s.client, userV3, authactions.OpenLdap) require.NoError(s.T(), err, "Auth user %s should be able to login", authUser.Username) mgmtUser, err := s.client.Management.User.ByID(authAdmin.UserID) @@ -676,18 +641,16 @@ func (s *SCIMOpenLDAPTestSuite) TestSCIMGroupRoleBindings() { logrus.Info("Verifying group CRTB for SCIM group") - groupName, groupID, err := scimactions.CreateSCIMGroup(s.scim, "") + groupName, groupID, err := scimactions.CreateSCIMGroup(s.scimClient, subSession, "") require.NoError(s.T(), err) - defer s.scim.Groups().Delete(groupID) var userIDs []string for i := 0; i < 2; i++ { - _, uid, err := scimactions.CreateSCIMUser(s.scim, "", false) + _, uid, err := scimactions.CreateSCIMUser(s.scimClient, subSession, "", false) require.NoError(s.T(), err) userIDs = append(userIDs, uid) - defer s.scim.Users().Delete(uid) - addResp, err := s.scim.Groups().Patch(groupID, scimclient.PatchOp{ + addResp, err := s.scimClient.Groups().Patch(groupID, scimclient.PatchOp{ Schemas: []string{scimclient.SCIMSchemaPatchOp}, Operations: []scimclient.Operation{ {Op: "add", Path: "members", Value: []scimclient.Member{{Value: uid}}}, @@ -697,7 +660,7 @@ func (s *SCIMOpenLDAPTestSuite) TestSCIMGroupRoleBindings() { require.NoError(s.T(), scimactions.CheckStatus(addResp, http.StatusOK, "PATCH add member to group should return 200")) } - groupPrincipal := fmt.Sprintf("%s_group://%s", scimProvider, groupName) + groupPrincipal := fmt.Sprintf("%s_group://%s", authactions.OpenLdap, groupName) logrus.Infof("Creating group CRTB for principal %s", groupPrincipal) crtb, err := rbacapi.CreateGroupClusterRoleTemplateBinding(s.client, s.cluster.ID, groupPrincipal, string(rbac.ClusterMember)) @@ -720,17 +683,12 @@ func (s *SCIMOpenLDAPTestSuite) TestSCIMUserPaginationMultiPage() { const numUsers = 12 var createdIDs []string for i := 0; i < numUsers; i++ { - _, uid, err := scimactions.CreateSCIMUser(s.scim, "", false) + _, uid, err := scimactions.CreateSCIMUser(s.scimClient, subSession, "", false) require.NoError(s.T(), err) createdIDs = append(createdIDs, uid) } - defer func() { - for _, id := range createdIDs { - _, _ = s.scim.Users().Delete(id) - } - }() - b1, err := scimactions.ListSCIMUsersPage(s.scim, 1, 5) + b1, err := scimactions.ListSCIMUsersPage(s.scimClient, 1, 5) require.NoError(s.T(), err) totalResults := int(b1["totalResults"].(float64)) require.GreaterOrEqual(s.T(), totalResults, numUsers, "totalResults should be at least the number of users we created") @@ -739,7 +697,7 @@ func (s *SCIMOpenLDAPTestSuite) TestSCIMUserPaginationMultiPage() { resources1, _ := b1["Resources"].([]interface{}) require.Len(s.T(), resources1, 5, "page 1 should return exactly 5 users") - b2, err := scimactions.ListSCIMUsersPage(s.scim, 6, 5) + b2, err := scimactions.ListSCIMUsersPage(s.scimClient, 6, 5) require.NoError(s.T(), err) require.Equal(s.T(), float64(totalResults), b2["totalResults"], "totalResults should be consistent across pages") require.Equal(s.T(), float64(6), b2["startIndex"]) @@ -747,7 +705,7 @@ func (s *SCIMOpenLDAPTestSuite) TestSCIMUserPaginationMultiPage() { resources2, _ := b2["Resources"].([]interface{}) require.Len(s.T(), resources2, 5, "page 2 should return exactly 5 users") - b3, err := scimactions.ListSCIMUsersPage(s.scim, 11, totalResults) + b3, err := scimactions.ListSCIMUsersPage(s.scimClient, 11, totalResults) require.NoError(s.T(), err) require.Equal(s.T(), float64(totalResults), b3["totalResults"], "totalResults should be consistent on page 3") require.Equal(s.T(), float64(11), b3["startIndex"]) @@ -776,9 +734,8 @@ func (s *SCIMOpenLDAPTestSuite) TestSCIMUserRoleBindingsWork() { logrus.Info("Creating SCIM user A with cluster-member CRTB") - _, userIDA, err := scimactions.CreateSCIMUser(s.scim, "", false) + _, userIDA, err := scimactions.CreateSCIMUser(s.scimClient, subSession, "", false) require.NoError(s.T(), err) - defer s.scim.Users().Delete(userIDA) mgmtUserA, err := s.client.Management.User.ByID(userIDA) require.NoError(s.T(), err, "Should be able to fetch Rancher user for SCIM user %s", userIDA) @@ -799,9 +756,8 @@ func (s *SCIMOpenLDAPTestSuite) TestSCIMUserRoleBindingsWork() { logrus.Info("Creating SCIM user B without any CRTB") - _, userIDB, err := scimactions.CreateSCIMUser(s.scim, "", false) + _, userIDB, err := scimactions.CreateSCIMUser(s.scimClient, subSession, "", false) require.NoError(s.T(), err) - defer s.scim.Users().Delete(userIDB) mgmtUserB, err := s.client.Management.User.ByID(userIDB) require.NoError(s.T(), err) @@ -820,9 +776,8 @@ func (s *SCIMOpenLDAPTestSuite) TestSCIMUsersAsClusterMembers() { for i := 0; i < numUsers; i++ { logrus.Infof("Creating SCIM cluster member %d", i+1) - _, userID, err := scimactions.CreateSCIMUser(s.scim, "", false) + _, userID, err := scimactions.CreateSCIMUser(s.scimClient, subSession, "", false) require.NoError(s.T(), err) - defer s.scim.Users().Delete(userID) mgmtUser, err := s.client.Management.User.ByID(userID) require.NoError(s.T(), err, "Rancher user %s should exist after SCIM create", userID) @@ -846,16 +801,16 @@ func (s *SCIMOpenLDAPTestSuite) TestSCIMZZDisableAuthCleansUpGroupsAndToken() { logrus.Info("Creating SCIM groups to verify they are deleted on auth provider disable") - _, groupID1, err := scimactions.CreateSCIMGroup(s.scim, "") + _, groupID1, err := scimactions.CreateSCIMGroup(s.scimClient, subSession, "") require.NoError(s.T(), err) - _, groupID2, err := scimactions.CreateSCIMGroup(s.scim, "") + _, groupID2, err := scimactions.CreateSCIMGroup(s.scimClient, subSession, "") require.NoError(s.T(), err) groupIDs := []string{groupID1, groupID2} - _, userID, err := scimactions.CreateSCIMUser(s.scim, "", false) + _, userID, err := scimactions.CreateSCIMUser(s.scimClient, subSession, "", false) require.NoError(s.T(), err) - _, err = scimactions.FetchSCIMBearerToken(s.client, scimProvider) + _, err = scimactions.FetchSCIMBearerToken(s.client, authactions.OpenLdap) require.NoError(s.T(), err, "Token secret should exist before disabling auth provider") logrus.Info("Disabling OpenLDAP auth provider to trigger SCIM cleanup") @@ -876,7 +831,7 @@ func (s *SCIMOpenLDAPTestSuite) TestSCIMZZDisableAuthCleansUpGroupsAndToken() { logrus.Info("Verifying SCIM token secret is deleted") err = scimactions.WaitForSCIMResourceDeletion(func() (int, error) { - _, fetchErr := scimactions.FetchSCIMBearerToken(s.client, scimProvider) + _, fetchErr := scimactions.FetchSCIMBearerToken(s.client, authactions.OpenLdap) if fetchErr != nil { return http.StatusNotFound, nil } @@ -898,15 +853,15 @@ func (s *SCIMOpenLDAPTestSuite) TestSCIMDeleteGroup() { subSession := s.session.NewSession() defer subSession.Cleanup() - _, groupID, err := scimactions.CreateSCIMGroup(s.scim, "") + _, groupID, err := scimactions.CreateSCIMGroup(s.scimClient, subSession, "") require.NoError(s.T(), err) - deleteResp, err := s.scim.Groups().Delete(groupID) + deleteResp, err := s.scimClient.Groups().Delete(groupID) require.NoError(s.T(), err) require.NoError(s.T(), scimactions.CheckStatus(deleteResp, http.StatusNoContent, "DELETE /Groups/{id} should return 204")) err = scimactions.WaitForSCIMResourceDeletion(func() (int, error) { - resp, pollErr := s.scim.Groups().ByID(groupID) + resp, pollErr := s.scimClient.Groups().ByID(groupID) if pollErr != nil { return 0, nil } @@ -921,19 +876,18 @@ func (s *SCIMOpenLDAPTestSuite) TestSCIMFilterGroupByDisplayName() { logrus.Info("Verifying GET /Groups?filter=displayName eq") - groupName, groupID, err := scimactions.CreateSCIMGroup(s.scim, "") + groupName, groupID, err := scimactions.CreateSCIMGroup(s.scimClient, subSession, "") require.NoError(s.T(), err) - defer s.scim.Groups().Delete(groupID) params := url.Values{} params.Set("filter", fmt.Sprintf("displayName eq %q", groupName)) - filterResp, err := s.scim.Groups().List(params) + filterResp, err := s.scimClient.Groups().List(params) require.NoError(s.T(), err) require.NoError(s.T(), scimactions.CheckStatus(filterResp, http.StatusOK, "Filtered GET /Groups should return 200")) - var body map[string]interface{} - require.NoError(s.T(), filterResp.DecodeJSON(&body)) + body, err := scimactions.DecodeJSONBody(filterResp) + require.NoError(s.T(), err) groupResources, _ := body["Resources"].([]interface{}) require.Len(s.T(), groupResources, 1, "Filter should return exactly 1 group") @@ -947,19 +901,18 @@ func (s *SCIMOpenLDAPTestSuite) TestSCIMGetGroupByIDExcludeMembersAttribute() { subSession := s.session.NewSession() defer subSession.Cleanup() - _, groupID, err := scimactions.CreateSCIMGroup(s.scim, "") + _, groupID, err := scimactions.CreateSCIMGroup(s.scimClient, subSession, "") require.NoError(s.T(), err) - defer s.scim.Groups().Delete(groupID) params := url.Values{} params.Set("excludedAttributes", "members") - resp, err := s.scim.Groups().ByIDWithQuery(groupID, params) + resp, err := s.scimClient.Groups().ByIDWithQuery(groupID, params) require.NoError(s.T(), err) require.NoError(s.T(), scimactions.CheckStatus(resp, http.StatusOK, "GET /Groups/{id}?excludedAttributes=members should return 200")) - var body map[string]interface{} - require.NoError(s.T(), resp.DecodeJSON(&body)) + body, err := scimactions.DecodeJSONBody(resp) + require.NoError(s.T(), err) require.Equal(s.T(), groupID, body["id"]) _, hasMembersField := body["members"] require.False(s.T(), hasMembersField, "members field should be absent when excludedAttributes=members") @@ -971,13 +924,12 @@ func (s *SCIMOpenLDAPTestSuite) TestSCIMPatchGroupReplaceExternalID() { logrus.Info("Verifying PATCH replace externalId for group") - groupName, groupID, err := scimactions.CreateSCIMGroup(s.scim, "") + groupName, groupID, err := scimactions.CreateSCIMGroup(s.scimClient, subSession, "") require.NoError(s.T(), err) - defer s.scim.Groups().Delete(groupID) newExternalID := "ext-" + groupName - patchResp, err := s.scim.Groups().Patch(groupID, scimclient.PatchOp{ + patchResp, err := s.scimClient.Groups().Patch(groupID, scimclient.PatchOp{ Schemas: []string{scimclient.SCIMSchemaPatchOp}, Operations: []scimclient.Operation{ {Op: "replace", Path: "externalId", Value: newExternalID}, @@ -986,24 +938,48 @@ func (s *SCIMOpenLDAPTestSuite) TestSCIMPatchGroupReplaceExternalID() { require.NoError(s.T(), err) require.NoError(s.T(), scimactions.CheckStatus(patchResp, http.StatusOK, "PATCH replace externalId should return 200")) - var body map[string]interface{} - require.NoError(s.T(), patchResp.DecodeJSON(&body)) + body, err := scimactions.DecodeJSONBody(patchResp) + require.NoError(s.T(), err) require.Equal(s.T(), newExternalID, body["externalId"], "externalId should be updated after PATCH replace") } +func (s *SCIMOpenLDAPTestSuite) TestSCIMPatchGroupAddExternalID() { + subSession := s.session.NewSession() + defer subSession.Cleanup() + + logrus.Info("Verifying PATCH op:add externalId for group is treated as replace") + + groupName, groupID, err := scimactions.CreateSCIMGroup(s.scimClient, subSession, "") + require.NoError(s.T(), err) + + newExternalID := "ext-add-" + groupName + + patchResp, err := s.scimClient.Groups().Patch(groupID, scimclient.PatchOp{ + Schemas: []string{scimclient.SCIMSchemaPatchOp}, + Operations: []scimclient.Operation{ + {Op: "add", Path: "externalId", Value: newExternalID}, + }, + }) + require.NoError(s.T(), err) + require.NoError(s.T(), scimactions.CheckStatus(patchResp, http.StatusOK, "PATCH op:add externalId should return 200 (treated as replace)")) + + body, err := scimactions.DecodeJSONBody(patchResp) + require.NoError(s.T(), err) + require.Equal(s.T(), newExternalID, body["externalId"], "externalId should be updated after PATCH op:add") +} + func (s *SCIMOpenLDAPTestSuite) TestSCIMPatchUserReplaceExternalID() { subSession := s.session.NewSession() defer subSession.Cleanup() logrus.Info("Verifying PATCH replace externalId for user") - userName, userID, err := scimactions.CreateSCIMUser(s.scim, "", false) + userName, userID, err := scimactions.CreateSCIMUser(s.scimClient, subSession, "", false) require.NoError(s.T(), err) - defer s.scim.Users().Delete(userID) newExternalID := "ext-" + userName - patchResp, err := s.scim.Users().Patch(userID, scimclient.PatchOp{ + patchResp, err := s.scimClient.Users().Patch(userID, scimclient.PatchOp{ Schemas: []string{scimclient.SCIMSchemaPatchOp}, Operations: []scimclient.Operation{ {Op: "replace", Path: "externalId", Value: newExternalID}, @@ -1012,8 +988,8 @@ func (s *SCIMOpenLDAPTestSuite) TestSCIMPatchUserReplaceExternalID() { require.NoError(s.T(), err) require.NoError(s.T(), scimactions.CheckStatus(patchResp, http.StatusOK, "PATCH replace externalId should return 200")) - var body map[string]interface{} - require.NoError(s.T(), patchResp.DecodeJSON(&body)) + body, err := scimactions.DecodeJSONBody(patchResp) + require.NoError(s.T(), err) require.Equal(s.T(), newExternalID, body["externalId"], "externalId should be updated after PATCH replace") } @@ -1023,13 +999,12 @@ func (s *SCIMOpenLDAPTestSuite) TestSCIMPatchUserReplacePrimaryEmail() { logrus.Info("Verifying PATCH replace primary email for user") - userName, userID, err := scimactions.CreateSCIMUser(s.scim, "", false) + userName, userID, err := scimactions.CreateSCIMUser(s.scimClient, subSession, "", false) require.NoError(s.T(), err) - defer s.scim.Users().Delete(userID) newEmail := userName + "@example.com" - patchResp, err := s.scim.Users().Patch(userID, scimclient.PatchOp{ + patchResp, err := s.scimClient.Users().Patch(userID, scimclient.PatchOp{ Schemas: []string{scimclient.SCIMSchemaPatchOp}, Operations: []scimclient.Operation{ {Op: "replace", Path: "emails[primary eq true].value", Value: newEmail}, @@ -1038,8 +1013,8 @@ func (s *SCIMOpenLDAPTestSuite) TestSCIMPatchUserReplacePrimaryEmail() { require.NoError(s.T(), err) require.NoError(s.T(), scimactions.CheckStatus(patchResp, http.StatusOK, "PATCH replace primary email should return 200")) - var body map[string]interface{} - require.NoError(s.T(), patchResp.DecodeJSON(&body)) + body, err := scimactions.DecodeJSONBody(patchResp) + require.NoError(s.T(), err) emails, _ := body["emails"].([]interface{}) require.Len(s.T(), emails, 1, "User should have exactly 1 email after PATCH") @@ -1055,7 +1030,7 @@ func (s *SCIMOpenLDAPTestSuite) TestSCIMResourceTypeByID() { logrus.Info("Verifying GET /ResourceTypes/{id} for User and Group") for _, resourceTypeID := range []string{"User", "Group"} { - resp, err := s.scim.Discovery().ResourceTypeByID(resourceTypeID) + resp, err := s.scimClient.Discovery().ResourceTypeByID(resourceTypeID) require.NoError(s.T(), err) require.NoError(s.T(), scimactions.CheckStatus(resp, http.StatusOK, fmt.Sprintf("GET /ResourceTypes/%s should return 200", resourceTypeID))) @@ -1065,7 +1040,7 @@ func (s *SCIMOpenLDAPTestSuite) TestSCIMResourceTypeByID() { require.Equal(s.T(), resourceTypeID, body["name"]) } - notFoundResp, err := s.scim.Discovery().ResourceTypeByID("nonexistent") + notFoundResp, err := s.scimClient.Discovery().ResourceTypeByID("nonexistent") require.NoError(s.T(), err) require.NoError(s.T(), scimactions.CheckStatus(notFoundResp, http.StatusNotFound, "GET /ResourceTypes/nonexistent should return 404")) } @@ -1076,12 +1051,12 @@ func (s *SCIMOpenLDAPTestSuite) TestSCIMSchemas() { logrus.Info("Verifying GET /Schemas returns User and Group schemas") - resp, err := s.scim.Discovery().Schemas() + resp, err := s.scimClient.Discovery().Schemas() require.NoError(s.T(), err) require.NoError(s.T(), scimactions.CheckStatus(resp, http.StatusOK, "GET /Schemas should return 200")) - var body map[string]interface{} - require.NoError(s.T(), resp.DecodeJSON(&body)) + body, err := scimactions.DecodeJSONBody(resp) + require.NoError(s.T(), err) require.Contains(s.T(), body, "totalResults") resourceTypes, _ := body["Resources"].([]interface{}) @@ -1110,7 +1085,7 @@ func (s *SCIMOpenLDAPTestSuite) TestSCIMSchemaByID() { logrus.Info("Verifying GET /Schemas/{id} for User and Group schemas") for _, schemaID := range []string{scimclient.SCIMSchemaUser, scimclient.SCIMSchemaGroup} { - resp, err := s.scim.Discovery().SchemaByID(schemaID) + resp, err := s.scimClient.Discovery().SchemaByID(schemaID) require.NoError(s.T(), err) require.NoError(s.T(), scimactions.CheckStatus(resp, http.StatusOK, fmt.Sprintf("GET /Schemas/%s should return 200", schemaID))) @@ -1120,7 +1095,7 @@ func (s *SCIMOpenLDAPTestSuite) TestSCIMSchemaByID() { require.Contains(s.T(), body, "attributes", "Schema should have attributes field") } - notFoundResp, err := s.scim.Discovery().SchemaByID("urn:nonexistent") + notFoundResp, err := s.scimClient.Discovery().SchemaByID("urn:nonexistent") require.NoError(s.T(), err) require.NoError(s.T(), scimactions.CheckStatus(notFoundResp, http.StatusNotFound, "GET /Schemas/nonexistent should return 404")) } @@ -1131,12 +1106,11 @@ func (s *SCIMOpenLDAPTestSuite) TestSCIMUpdateGroup() { logrus.Info("Verifying PUT /Groups/{id}") - groupName, groupID, err := scimactions.CreateSCIMGroup(s.scim, "") + groupName, groupID, err := scimactions.CreateSCIMGroup(s.scimClient, subSession, "") require.NoError(s.T(), err) - defer s.scim.Groups().Delete(groupID) newExternalID := "ext-" + groupName - updateResp, err := s.scim.Groups().Update(groupID, scimclient.Group{ + updateResp, err := s.scimClient.Groups().Update(groupID, scimclient.Group{ Schemas: []string{scimclient.SCIMSchemaGroup}, ID: groupID, DisplayName: groupName, @@ -1145,8 +1119,8 @@ func (s *SCIMOpenLDAPTestSuite) TestSCIMUpdateGroup() { require.NoError(s.T(), err) require.NoError(s.T(), scimactions.CheckStatus(updateResp, http.StatusOK, "PUT /Groups/{id} should return 200")) - var body map[string]interface{} - require.NoError(s.T(), updateResp.DecodeJSON(&body)) + body, err := scimactions.DecodeJSONBody(updateResp) + require.NoError(s.T(), err) require.Equal(s.T(), groupID, body["id"]) require.Equal(s.T(), groupName, body["displayName"]) require.Equal(s.T(), newExternalID, body["externalId"], "externalId should be updated after PUT") @@ -1158,12 +1132,11 @@ func (s *SCIMOpenLDAPTestSuite) TestSCIMUpdateUser() { logrus.Info("Verifying PUT /Users/{id}") - userName, userID, err := scimactions.CreateSCIMUser(s.scim, "", false) + userName, userID, err := scimactions.CreateSCIMUser(s.scimClient, subSession, "", false) require.NoError(s.T(), err) - defer s.scim.Users().Delete(userID) newExternalID := "ext-" + userName - updateResp, err := s.scim.Users().Update(userID, scimclient.User{ + updateResp, err := s.scimClient.Users().Update(userID, scimclient.User{ Schemas: []string{scimclient.SCIMSchemaUser}, UserName: userName, ExternalID: newExternalID, @@ -1172,8 +1145,8 @@ func (s *SCIMOpenLDAPTestSuite) TestSCIMUpdateUser() { require.NoError(s.T(), err) require.NoError(s.T(), scimactions.CheckStatus(updateResp, http.StatusOK, "PUT /Users/{id} should return 200")) - var body map[string]interface{} - require.NoError(s.T(), updateResp.DecodeJSON(&body)) + body, err := scimactions.DecodeJSONBody(updateResp) + require.NoError(s.T(), err) require.Equal(s.T(), userID, body["id"]) require.Equal(s.T(), userName, body["userName"]) require.Equal(s.T(), newExternalID, body["externalId"], "externalId should be updated after PUT") @@ -1185,20 +1158,21 @@ func (s *SCIMOpenLDAPTestSuite) TestSCIMUserProjectRoleBinding() { logrus.Info("Verifying PRTB for SCIM user as project-owner") - _, userID, err := scimactions.CreateSCIMUser(s.scim, "", false) + _, userID, err := scimactions.CreateSCIMUser(s.scimClient, subSession, "", false) require.NoError(s.T(), err, "Should be able to create SCIM user") - defer s.scim.Users().Delete(userID) mgmtUser, err := s.client.Management.User.ByID(userID) require.NoError(s.T(), err, "Rancher user %s should exist after SCIM create", userID) - project, err := s.client.WranglerContext.Mgmt.Project().Create(projectapi.NewProjectTemplate(s.cluster.ID)) + rawProject, err := s.client.WranglerContext.Mgmt.Project().Create(projectapi.NewProjectTemplate(s.cluster.ID)) require.NoError(s.T(), err, "Should be able to create a project in cluster %s", s.cluster.ID) - prtbNamespace := project.Name - if project.Status.BackingNamespace != "" { - prtbNamespace = project.Status.BackingNamespace - } + project, err := projectapi.WaitForProjectBackingNamespace(s.client, s.cluster.ID, rawProject.Name) + require.NoError(s.T(), err, "Project backing namespace should be provisioned in cluster %s", s.cluster.ID) + + prtbNamespace := project.Status.BackingNamespace + require.NoError(s.T(), authactions.WaitForNamespaceReady(s.client, prtbNamespace), + "Project backing namespace %s should be ready before creating PRTB", prtbNamespace) projectName := fmt.Sprintf("%s:%s", project.Namespace, project.Name) @@ -1223,6 +1197,922 @@ func (s *SCIMOpenLDAPTestSuite) TestSCIMUserProjectRoleBinding() { require.Equal(s.T(), mgmtUser.ID, prtbs[0].UserName, "PRTB should be bound to the correct user") } +func (s *SCIMOpenLDAPTestSuite) TestSCIMNoConfigMapAllEndpointsReturn404() { + subSession := s.session.NewSession() + defer subSession.Cleanup() + + logrus.Info("Verifying all SCIM endpoints return 404 with SCIM error body when ConfigMap is absent") + + defer func() { require.NoError(s.T(), scimactions.RestoreSCIMBaseline(s.client, s.scimClient, authactions.OpenLdap)) }() + require.NoError(s.T(), scimactions.DeleteSCIMConfigMap(s.client, authactions.OpenLdap)) + require.NoError(s.T(), scimactions.WaitForSCIMEndpointStatus(s.scimClient, http.StatusNotFound)) + + endpoints := []struct { + name string + call func() (*scimclient.Response, error) + }{ + {"GET /Users", func() (*scimclient.Response, error) { return s.scimClient.Users().List(nil) }}, + {"GET /Groups", func() (*scimclient.Response, error) { return s.scimClient.Groups().List(nil) }}, + {"GET /ServiceProviderConfig", func() (*scimclient.Response, error) { return s.scimClient.Discovery().ServiceProviderConfig() }}, + {"GET /ResourceTypes", func() (*scimclient.Response, error) { return s.scimClient.Discovery().ResourceTypes() }}, + {"GET /Schemas", func() (*scimclient.Response, error) { return s.scimClient.Discovery().Schemas() }}, + } + for _, ep := range endpoints { + resp, err := ep.call() + require.NoError(s.T(), err, "%s should not error", ep.name) + require.NoError(s.T(), scimactions.CheckStatus(resp, http.StatusNotFound, fmt.Sprintf("%s should return 404 when ConfigMap absent", ep.name))) + require.NoError(s.T(), scimactions.ValidateSCIMErrorBody(resp, http.StatusNotFound), "%s 404 body should match SCIM error schema", ep.name) + } +} + +func (s *SCIMOpenLDAPTestSuite) TestSCIMCMNoConfigMapRouteGatingBeforeAuth() { + subSession := s.session.NewSession() + defer subSession.Cleanup() + + logrus.Info("Verifying invalid bearer token also returns 404 when ConfigMap absent (route gating before auth)") + + defer func() { require.NoError(s.T(), scimactions.RestoreSCIMBaseline(s.client, s.scimClient, authactions.OpenLdap)) }() + require.NoError(s.T(), scimactions.DeleteSCIMConfigMap(s.client, authactions.OpenLdap)) + require.NoError(s.T(), scimactions.WaitForSCIMEndpointStatus(s.scimClient, http.StatusNotFound)) + + badClient := scimactions.NewSCIMClientWithToken(s.client.RancherConfig.Host, authactions.OpenLdap, "invalid-token") + resp, err := badClient.Users().List(nil) + require.NoError(s.T(), err) + require.NoError(s.T(), scimactions.CheckStatus(resp, http.StatusNotFound, "Invalid token should still return 404 when ConfigMap absent (not 401)")) +} + +func (s *SCIMOpenLDAPTestSuite) TestSCIMCMEnabledTrueReturns200() { + subSession := s.session.NewSession() + defer subSession.Cleanup() + + logrus.Info("Verifying enabled=true returns 200 on /Users after being disabled") + + defer func() { require.NoError(s.T(), scimactions.RestoreSCIMBaseline(s.client, s.scimClient, authactions.OpenLdap)) }() + require.NoError(s.T(), scimactions.SetSCIMConfigMapAndWait(s.client, s.scimClient, authactions.OpenLdap, map[string]string{"enabled": "false"}, http.StatusNotFound)) + require.NoError(s.T(), scimactions.SetSCIMConfigMapAndWait(s.client, s.scimClient, authactions.OpenLdap, scimactions.BaselineSCIMConfigMap(), http.StatusOK)) + + resp, err := s.scimClient.Users().List(nil) + require.NoError(s.T(), err) + require.NoError(s.T(), scimactions.CheckStatus(resp, http.StatusOK, "GET /Users should return 200 with enabled=true")) +} + +func (s *SCIMOpenLDAPTestSuite) TestSCIMCMEnabledFalseReturns404() { + subSession := s.session.NewSession() + defer subSession.Cleanup() + + logrus.Info("Verifying enabled=false returns 404 on /Users") + + defer func() { require.NoError(s.T(), scimactions.RestoreSCIMBaseline(s.client, s.scimClient, authactions.OpenLdap)) }() + require.NoError(s.T(), scimactions.SetSCIMConfigMapAndWait(s.client, s.scimClient, authactions.OpenLdap, map[string]string{"enabled": "false"}, http.StatusNotFound)) + + resp, err := s.scimClient.Users().List(nil) + require.NoError(s.T(), err) + require.NoError(s.T(), scimactions.CheckStatus(resp, http.StatusNotFound, "GET /Users should return 404 with enabled=false")) +} + +func (s *SCIMOpenLDAPTestSuite) TestSCIMCMPausedReturns503() { + subSession := s.session.NewSession() + defer subSession.Cleanup() + + logrus.Info("Verifying paused=true returns 503 on /Users") + + defer func() { require.NoError(s.T(), scimactions.RestoreSCIMBaseline(s.client, s.scimClient, authactions.OpenLdap)) }() + require.NoError(s.T(), scimactions.SetSCIMConfigMapAndWait(s.client, s.scimClient, authactions.OpenLdap, map[string]string{"enabled": "true", "paused": "true"}, http.StatusServiceUnavailable)) + + resp, err := s.scimClient.Users().List(nil) + require.NoError(s.T(), err) + require.NoError(s.T(), scimactions.CheckStatus(resp, http.StatusServiceUnavailable, "GET /Users should return 503 when paused")) + require.NoError(s.T(), scimactions.ValidateSCIMErrorBody(resp, http.StatusServiceUnavailable)) +} + +func (s *SCIMOpenLDAPTestSuite) TestSCIMCMUnpauseResumes() { + subSession := s.session.NewSession() + defer subSession.Cleanup() + + logrus.Info("Verifying unpause (paused=false) resumes SCIM endpoints") + + defer func() { require.NoError(s.T(), scimactions.RestoreSCIMBaseline(s.client, s.scimClient, authactions.OpenLdap)) }() + require.NoError(s.T(), scimactions.SetSCIMConfigMapAndWait(s.client, s.scimClient, authactions.OpenLdap, map[string]string{"enabled": "true", "paused": "true"}, http.StatusServiceUnavailable)) + require.NoError(s.T(), scimactions.SetSCIMConfigMapAndWait(s.client, s.scimClient, authactions.OpenLdap, map[string]string{"enabled": "true", "paused": "false"}, http.StatusOK)) + + resp, err := s.scimClient.Users().List(nil) + require.NoError(s.T(), err) + require.NoError(s.T(), scimactions.CheckStatus(resp, http.StatusOK, "GET /Users should return 200 after unpause")) +} + +func (s *SCIMOpenLDAPTestSuite) TestSCIMCMUserIdExternalIDPrincipal() { + subSession := s.session.NewSession() + defer subSession.Cleanup() + + logrus.Info("Verifying userIdAttribute=externalId builds Rancher user principalID from externalId") + + defer func() { require.NoError(s.T(), scimactions.RestoreSCIMBaseline(s.client, s.scimClient, authactions.OpenLdap)) }() + require.NoError(s.T(), scimactions.SetSCIMConfigMapAndWait(s.client, s.scimClient, authactions.OpenLdap, map[string]string{"enabled": "true", "userIdAttribute": "externalId"}, http.StatusOK)) + + externalID := namegen.AppendRandomString("ext") + _, userID, err := scimactions.CreateSCIMUser(s.scimClient, subSession, externalID, true) + require.NoError(s.T(), err) + + mgmtUser, err := s.client.Management.User.ByID(userID) + require.NoError(s.T(), err, "Should fetch Rancher user for SCIM user %s", userID) + require.NotEmpty(s.T(), mgmtUser.PrincipalIDs, "Rancher user should have principalIDs") + + found := false + for _, pid := range mgmtUser.PrincipalIDs { + if strings.Contains(pid, externalID) { + found = true + break + } + } + require.True(s.T(), found, "At least one principalID should contain externalId %q (got %v)", externalID, mgmtUser.PrincipalIDs) +} + +func (s *SCIMOpenLDAPTestSuite) TestSCIMCMUserNameChangeAcceptedWithExternalIDPrincipal() { + subSession := s.session.NewSession() + defer subSession.Cleanup() + + logrus.Info("Verifying PATCH userName succeeds when userIdAttribute=externalId") + + defer func() { require.NoError(s.T(), scimactions.RestoreSCIMBaseline(s.client, s.scimClient, authactions.OpenLdap)) }() + require.NoError(s.T(), scimactions.SetSCIMConfigMapAndWait(s.client, s.scimClient, authactions.OpenLdap, map[string]string{"enabled": "true", "userIdAttribute": "externalId"}, http.StatusOK)) + + _, userID, err := scimactions.CreateSCIMUser(s.scimClient, subSession, namegen.AppendRandomString("ext"), true) + require.NoError(s.T(), err) + + newUserName := namegen.AppendRandomString("renamed") + "@example.com" + resp, err := s.scimClient.Users().Patch(userID, scimclient.PatchOp{ + Schemas: []string{scimclient.SCIMSchemaPatchOp}, + Operations: []scimclient.Operation{ + {Op: "replace", Path: "userName", Value: newUserName}, + }, + }) + require.NoError(s.T(), err) + require.NoError(s.T(), scimactions.CheckStatus(resp, http.StatusOK, "PATCH userName should return 200 when externalId is principal")) +} + +func (s *SCIMOpenLDAPTestSuite) TestSCIMCMUserNameChangeRejectedWithUserNamePrincipal() { + subSession := s.session.NewSession() + defer subSession.Cleanup() + + logrus.Info("Verifying PATCH userName returns 400 when userName is the principal (default)") + + require.NoError(s.T(), scimactions.SetSCIMConfigMapAndWait(s.client, s.scimClient, authactions.OpenLdap, scimactions.BaselineSCIMConfigMap(), http.StatusOK)) + + _, userID, err := scimactions.CreateSCIMUser(s.scimClient, subSession, "", true) + require.NoError(s.T(), err) + + resp, err := s.scimClient.Users().Patch(userID, scimclient.PatchOp{ + Schemas: []string{scimclient.SCIMSchemaPatchOp}, + Operations: []scimclient.Operation{ + {Op: "replace", Path: "userName", Value: "newname@example.com"}, + }, + }) + require.NoError(s.T(), err) + require.NoError(s.T(), scimactions.CheckStatus(resp, http.StatusBadRequest, "PATCH userName should return 400 when userName is principal")) + require.NoError(s.T(), scimactions.ValidateSCIMErrorBody(resp, http.StatusBadRequest)) +} + +func (s *SCIMOpenLDAPTestSuite) TestSCIMCMGroupIdExternalIDPrincipal() { + subSession := s.session.NewSession() + defer subSession.Cleanup() + + logrus.Info("Verifying groupIdAttribute=externalId is accepted and group created with externalId") + + defer func() { require.NoError(s.T(), scimactions.RestoreSCIMBaseline(s.client, s.scimClient, authactions.OpenLdap)) }() + require.NoError(s.T(), scimactions.SetSCIMConfigMapAndWait(s.client, s.scimClient, authactions.OpenLdap, map[string]string{"enabled": "true", "groupIdAttribute": "externalId"}, http.StatusOK)) + + externalID := namegen.AppendRandomString("grp-ext") + _, groupID, err := scimactions.CreateSCIMGroup(s.scimClient, subSession, externalID) + require.NoError(s.T(), err) + + getResp, err := s.scimClient.Groups().ByID(groupID) + require.NoError(s.T(), err) + require.NoError(s.T(), scimactions.CheckStatus(getResp, http.StatusOK, "GET /Groups/{id} should return 200")) + + body, err := scimactions.DecodeJSONBody(getResp) + require.NoError(s.T(), err) + require.Equal(s.T(), externalID, body["externalId"], "externalId should be set on the group") +} + +func (s *SCIMOpenLDAPTestSuite) TestSCIMCMDisplayNameFallsBackToUserName() { + subSession := s.session.NewSession() + defer subSession.Cleanup() + + logrus.Info("Verifying Rancher user.Name (displayName) falls back to userName when SCIM displayName is not set") + + userName, userID, err := scimactions.CreateSCIMUser(s.scimClient, subSession, "", true) + require.NoError(s.T(), err) + + mgmtUser, err := s.client.Management.User.ByID(userID) + require.NoError(s.T(), err, "Should fetch Rancher user for SCIM user %s", userID) + require.Equal(s.T(), userName, mgmtUser.Name, "Rancher user.Name should fall back to userName when SCIM displayName is not provided") +} + +func (s *SCIMOpenLDAPTestSuite) TestSCIMCMPatchUserDisplayName() { + subSession := s.session.NewSession() + defer subSession.Cleanup() + + logrus.Info("Verifying PATCH replace displayName on user succeeds and is reflected in Rancher user record") + + _, userID, err := scimactions.CreateSCIMUser(s.scimClient, subSession, "", true) + require.NoError(s.T(), err) + + newDisplayName := namegen.AppendRandomString("display") + patchResp, err := s.scimClient.Users().Patch(userID, scimclient.PatchOp{ + Schemas: []string{scimclient.SCIMSchemaPatchOp}, + Operations: []scimclient.Operation{ + {Op: "replace", Path: "displayName", Value: newDisplayName}, + }, + }) + require.NoError(s.T(), err) + require.NoError(s.T(), scimactions.CheckStatus(patchResp, http.StatusOK, "PATCH displayName should return 200")) + + err = kwait.PollUntilContextTimeout(context.Background(), defaults.FiveSecondTimeout, defaults.OneMinuteTimeout, false, func(ctx context.Context) (bool, error) { + mgmtUser, getErr := s.client.Management.User.ByID(userID) + if getErr != nil { + return false, nil + } + return mgmtUser.Name == newDisplayName, nil + }) + require.NoError(s.T(), err, "Rancher user.Name should reflect SCIM displayName %q after PATCH", newDisplayName) +} + +func (s *SCIMOpenLDAPTestSuite) TestSCIMCMFilterUserByExternalID() { + subSession := s.session.NewSession() + defer subSession.Cleanup() + + logrus.Info("Verifying GET /Users?filter=externalId eq returns matching user") + + externalID := namegen.AppendRandomString("ext-filter") + _, _, err := scimactions.CreateSCIMUser(s.scimClient, subSession, externalID, true) + require.NoError(s.T(), err) + + params := url.Values{} + params.Set("filter", fmt.Sprintf("externalId eq %q", externalID)) + + resp, err := s.scimClient.Users().List(params) + require.NoError(s.T(), err) + require.NoError(s.T(), scimactions.CheckStatus(resp, http.StatusOK, "Filtered GET /Users should return 200")) + + body, err := scimactions.DecodeJSONBody(resp) + require.NoError(s.T(), err) + userResources, _ := body["Resources"].([]interface{}) + require.Len(s.T(), userResources, 1, "Filter by externalId should return exactly 1 user") + + firstUser, _ := userResources[0].(map[string]interface{}) + require.Equal(s.T(), externalID, firstUser["externalId"], "Returned user externalId should match filter") +} + +func (s *SCIMOpenLDAPTestSuite) TestSCIMCMFilterGroupByExternalID() { + subSession := s.session.NewSession() + defer subSession.Cleanup() + + logrus.Info("Verifying GET /Groups?filter=externalId eq returns matching group") + + externalID := namegen.AppendRandomString("grp-ext-filter") + _, _, err := scimactions.CreateSCIMGroup(s.scimClient, subSession, externalID) + require.NoError(s.T(), err) + + params := url.Values{} + params.Set("filter", fmt.Sprintf("externalId eq %q", externalID)) + + resp, err := s.scimClient.Groups().List(params) + require.NoError(s.T(), err) + require.NoError(s.T(), scimactions.CheckStatus(resp, http.StatusOK, "Filtered GET /Groups should return 200")) + + body, err := scimactions.DecodeJSONBody(resp) + require.NoError(s.T(), err) + groupResources, _ := body["Resources"].([]interface{}) + require.Len(s.T(), groupResources, 1, "Filter by externalId should return exactly 1 group") + + firstGroup, _ := groupResources[0].(map[string]interface{}) + require.Equal(s.T(), externalID, firstGroup["externalId"], "Returned group externalId should match filter") +} + +func (s *SCIMOpenLDAPTestSuite) TestSCIMCMInvalidUserIdAttributeFallsBack() { + subSession := s.session.NewSession() + defer subSession.Cleanup() + + logrus.Info("Verifying invalid userIdAttribute value falls back to default and does not crash SCIM") + + defer func() { require.NoError(s.T(), scimactions.RestoreSCIMBaseline(s.client, s.scimClient, authactions.OpenLdap)) }() + require.NoError(s.T(), scimactions.SetSCIMConfigMapAndWait(s.client, s.scimClient, authactions.OpenLdap, map[string]string{"enabled": "true", "userIdAttribute": "badvalue"}, http.StatusOK)) + + _, userID, err := scimactions.CreateSCIMUser(s.scimClient, subSession, "", true) + require.NoError(s.T(), err) + require.NotEmpty(s.T(), userID, "POST /Users should still succeed with invalid userIdAttribute (fallback to default)") + + resp, err := s.scimClient.Users().List(nil) + require.NoError(s.T(), err) + require.NoError(s.T(), scimactions.CheckStatus(resp, http.StatusOK, "GET /Users should still return 200 with invalid userIdAttribute")) +} + +func (s *SCIMOpenLDAPTestSuite) TestSCIMURNPatchUserActive() { + subSession := s.session.NewSession() + defer subSession.Cleanup() + + logrus.Info("Verifying PATCH user with URN-prefixed active path behaves like bare path") + + _, userID, err := scimactions.CreateSCIMUser(s.scimClient, subSession, "", true) + require.NoError(s.T(), err) + + patchResp, err := scimactions.PatchUser(s.scimClient, userID, "replace", "urn:ietf:params:scim:schemas:core:2.0:User:active", false) + require.NoError(s.T(), err) + require.NoError(s.T(), scimactions.CheckStatus(patchResp, http.StatusOK, "PATCH URN-prefixed active should return 200")) + + body, err := scimactions.DecodeJSONBody(patchResp) + require.NoError(s.T(), err) + require.Equal(s.T(), false, body["active"], "active should be false after URN-prefixed PATCH") +} + +func (s *SCIMOpenLDAPTestSuite) TestSCIMURNPatchUserDisplayName() { + subSession := s.session.NewSession() + defer subSession.Cleanup() + + logrus.Info("Verifying PATCH user with URN-prefixed displayName path") + + _, userID, err := scimactions.CreateSCIMUser(s.scimClient, subSession, "", true) + require.NoError(s.T(), err) + + newDisplayName := namegen.AppendRandomString("urn-display") + patchResp, err := scimactions.PatchUser(s.scimClient, userID, "replace", "urn:ietf:params:scim:schemas:core:2.0:User:displayName", newDisplayName) + require.NoError(s.T(), err) + require.NoError(s.T(), scimactions.CheckStatus(patchResp, http.StatusOK, "PATCH URN-prefixed displayName should return 200")) + + require.NoError(s.T(), scimactions.WaitForRancherUserName(s.client,userID, newDisplayName), "Rancher user.Name should reflect URN-prefixed displayName %q after PATCH", newDisplayName) +} + +func (s *SCIMOpenLDAPTestSuite) TestSCIMURNPatchUserName() { + subSession := s.session.NewSession() + defer subSession.Cleanup() + + logrus.Info("Verifying PATCH URN-prefixed userName succeeds when userIdAttribute=externalId") + + defer func() { require.NoError(s.T(), scimactions.RestoreSCIMBaseline(s.client, s.scimClient, authactions.OpenLdap)) }() + require.NoError(s.T(), scimactions.SetSCIMConfigMapAndWait(s.client, s.scimClient, authactions.OpenLdap, map[string]string{"enabled": "true", "userIdAttribute": "externalId"}, http.StatusOK)) + + _, userID, err := scimactions.CreateSCIMUser(s.scimClient, subSession, namegen.AppendRandomString("ext"), true) + require.NoError(s.T(), err) + + newUserName := namegen.AppendRandomString("urn-rename") + "@example.com" + patchResp, err := scimactions.PatchUser(s.scimClient, userID, "replace", "urn:ietf:params:scim:schemas:core:2.0:User:userName", newUserName) + require.NoError(s.T(), err) + require.NoError(s.T(), scimactions.CheckStatus(patchResp, http.StatusOK, "PATCH URN-prefixed userName should return 200 with externalId principal")) +} + +func (s *SCIMOpenLDAPTestSuite) TestSCIMURNPatchGroupExternalID() { + subSession := s.session.NewSession() + defer subSession.Cleanup() + + logrus.Info("Verifying PATCH group with URN-prefixed externalId path") + + groupName, groupID, err := scimactions.CreateSCIMGroup(s.scimClient, subSession, "") + require.NoError(s.T(), err) + + newExternalID := "ext-urn-" + groupName + patchResp, err := scimactions.PatchGroup(s.scimClient, groupID, "replace", "urn:ietf:params:scim:schemas:core:2.0:Group:externalId", newExternalID) + require.NoError(s.T(), err) + require.NoError(s.T(), scimactions.CheckStatus(patchResp, http.StatusOK, "PATCH URN-prefixed group externalId should return 200")) + + body, err := scimactions.DecodeJSONBody(patchResp) + require.NoError(s.T(), err) + require.Equal(s.T(), newExternalID, body["externalId"], "externalId should be updated") +} + +func (s *SCIMOpenLDAPTestSuite) TestSCIMURNPatchGroupAddExternalID() { + subSession := s.session.NewSession() + defer subSession.Cleanup() + + logrus.Info("Verifying PATCH group op:add with URN-prefixed externalId is treated as replace") + + groupName, groupID, err := scimactions.CreateSCIMGroup(s.scimClient, subSession, "") + require.NoError(s.T(), err) + + newExternalID := "ext-urn-add-" + groupName + patchResp, err := scimactions.PatchGroup(s.scimClient, groupID, "add", "urn:ietf:params:scim:schemas:core:2.0:Group:externalId", newExternalID) + require.NoError(s.T(), err) + require.NoError(s.T(), scimactions.CheckStatus(patchResp, http.StatusOK, "PATCH op:add URN-prefixed group externalId should return 200 (treated as replace)")) + + body, err := scimactions.DecodeJSONBody(patchResp) + require.NoError(s.T(), err) + require.Equal(s.T(), newExternalID, body["externalId"], "externalId should be updated after PATCH op:add with URN prefix") +} + +func (s *SCIMOpenLDAPTestSuite) TestSCIMURNPatchGroupAddMember() { + subSession := s.session.NewSession() + defer subSession.Cleanup() + + logrus.Info("Verifying PATCH group op:add with URN-prefixed members path") + + _, userID, err := scimactions.CreateSCIMUser(s.scimClient, subSession, "", true) + require.NoError(s.T(), err) + _, groupID, err := scimactions.CreateSCIMGroup(s.scimClient, subSession, "") + require.NoError(s.T(), err) + + patchResp, err := scimactions.PatchGroup(s.scimClient, groupID, "add", "urn:ietf:params:scim:schemas:core:2.0:Group:members", []scimclient.Member{{Value: userID}}) + require.NoError(s.T(), err) + require.NoError(s.T(), scimactions.CheckStatus(patchResp, http.StatusOK, "PATCH op:add URN-prefixed members should return 200")) + require.NoError(s.T(), scimactions.WaitForGroupMemberCount(s.scimClient, groupID, 1)) +} + +func (s *SCIMOpenLDAPTestSuite) TestSCIMURNPatchGroupRemoveMember() { + subSession := s.session.NewSession() + defer subSession.Cleanup() + + logrus.Info("Verifying PATCH group op:remove with URN-prefixed members[value eq ...] path") + + _, userID, err := scimactions.CreateSCIMUser(s.scimClient, subSession, "", true) + require.NoError(s.T(), err) + _, groupID, err := scimactions.CreateSCIMGroup(s.scimClient, subSession, "") + require.NoError(s.T(), err) + + addResp, err := scimactions.PatchGroup(s.scimClient, groupID, "add", "members", []scimclient.Member{{Value: userID}}) + require.NoError(s.T(), err) + require.NoError(s.T(), scimactions.CheckStatus(addResp, http.StatusOK, "PATCH add member should return 200")) + + removePath := fmt.Sprintf("urn:ietf:params:scim:schemas:core:2.0:Group:members[value eq %q]", userID) + removeResp, err := scimactions.PatchGroup(s.scimClient, groupID, "remove", removePath, nil) + require.NoError(s.T(), err) + require.NoError(s.T(), scimactions.CheckStatus(removeResp, http.StatusOK, "PATCH op:remove URN-prefixed members should return 200")) + require.NoError(s.T(), scimactions.WaitForGroupMemberCount(s.scimClient, groupID, 0)) +} + +func (s *SCIMOpenLDAPTestSuite) TestSCIMURNFilterUser() { + subSession := s.session.NewSession() + defer subSession.Cleanup() + + logrus.Info("Verifying GET /Users?filter with URN-prefixed attribute path") + + userName, _, err := scimactions.CreateSCIMUser(s.scimClient, subSession, "", true) + require.NoError(s.T(), err) + + params := url.Values{} + params.Set("filter", fmt.Sprintf("urn:ietf:params:scim:schemas:core:2.0:User:userName eq %q", userName)) + + resp, err := s.scimClient.Users().List(params) + require.NoError(s.T(), err) + require.NoError(s.T(), scimactions.CheckStatus(resp, http.StatusOK, "URN-prefixed filter on /Users should return 200")) + + body, err := scimactions.DecodeJSONBody(resp) + require.NoError(s.T(), err) + userResources, _ := body["Resources"].([]interface{}) + require.Len(s.T(), userResources, 1, "URN-prefixed filter should return exactly 1 user") + firstUser, _ := userResources[0].(map[string]interface{}) + require.Equal(s.T(), userName, firstUser["userName"], "Returned user userName should match filter") +} + +func (s *SCIMOpenLDAPTestSuite) TestSCIMURNCrossResourceMismatchReturns400() { + subSession := s.session.NewSession() + defer subSession.Cleanup() + + logrus.Info("Verifying PATCH group with User URN-prefixed path returns 400") + + _, groupID, err := scimactions.CreateSCIMGroup(s.scimClient, subSession, "") + require.NoError(s.T(), err) + + patchResp, err := scimactions.PatchGroup(s.scimClient, groupID, "replace", "urn:ietf:params:scim:schemas:core:2.0:User:displayName", "wrong") + require.NoError(s.T(), err) + require.NoError(s.T(), scimactions.CheckStatus(patchResp, http.StatusBadRequest, "User URN on Group endpoint should return 400")) + require.NoError(s.T(), scimactions.ValidateSCIMErrorBody(patchResp, http.StatusBadRequest)) +} + +func (s *SCIMOpenLDAPTestSuite) TestSCIMURNUnknownURNReturns400() { + subSession := s.session.NewSession() + defer subSession.Cleanup() + + logrus.Info("Verifying PATCH user with unknown URN path returns 400 and does not panic") + + _, userID, err := scimactions.CreateSCIMUser(s.scimClient, subSession, "", true) + require.NoError(s.T(), err) + + patchResp, err := scimactions.PatchUser(s.scimClient, userID, "replace", "urn:custom:extension:1.0:Custom:foo", "bar") + require.NoError(s.T(), err) + require.NoError(s.T(), scimactions.CheckStatus(patchResp, http.StatusBadRequest, "Unknown URN should return 400")) + require.NoError(s.T(), scimactions.ValidateSCIMErrorBody(patchResp, http.StatusBadRequest)) +} + +func (s *SCIMOpenLDAPTestSuite) TestSCIMURNBareAttributesRegression() { + subSession := s.session.NewSession() + defer subSession.Cleanup() + + logrus.Info("Verifying bare-path PATCH still works after URN-stripping addition (regression)") + + _, userID, err := scimactions.CreateSCIMUser(s.scimClient, subSession, "", false) + require.NoError(s.T(), err) + + userPatchResp, err := scimactions.PatchUser(s.scimClient, userID, "replace", "active", true) + require.NoError(s.T(), err) + require.NoError(s.T(), scimactions.CheckStatus(userPatchResp, http.StatusOK, "Bare-path PATCH active should still return 200")) + + groupName, groupID, err := scimactions.CreateSCIMGroup(s.scimClient, subSession, "") + require.NoError(s.T(), err) + + groupPatchResp, err := scimactions.PatchGroup(s.scimClient, groupID, "replace", "externalId", "ext-"+groupName) + require.NoError(s.T(), err) + require.NoError(s.T(), scimactions.CheckStatus(groupPatchResp, http.StatusOK, "Bare-path PATCH group externalId should still return 200")) +} + +func (s *SCIMOpenLDAPTestSuite) TestSCIMRLDisabledByDefault() { + subSession := s.session.NewSession() + defer subSession.Cleanup() + + logrus.Info("Verifying rate limiting is disabled by default (no rate keys in ConfigMap)") + + defer func() { require.NoError(s.T(), scimactions.RestoreSCIMBaseline(s.client, s.scimClient, authactions.OpenLdap)) }() + require.NoError(s.T(), scimactions.SetSCIMConfigMapAndWait(s.client, s.scimClient, authactions.OpenLdap, scimactions.BaselineSCIMConfigMap(), http.StatusOK)) + + results, err := scimactions.BurstSCIMRequests(s.scimClient, 50) + require.NoError(s.T(), err) + for i, code := range results { + require.Equal(s.T(), http.StatusOK, code, "request %d should return 200 when rate limiting disabled", i) + } +} + +func (s *SCIMOpenLDAPTestSuite) TestSCIMRLEnabledReturns429AfterBurst() { + subSession := s.session.NewSession() + defer subSession.Cleanup() + + logrus.Info("Verifying rate limit returns 429 when burst exceeded") + + defer func() { require.NoError(s.T(), scimactions.RestoreSCIMBaseline(s.client, s.scimClient, authactions.OpenLdap)) }() + require.NoError(s.T(), scimactions.SetSCIMConfigMap(s.client, authactions.OpenLdap, scimactions.RateLimitSCIMConfigMap(2, 5))) + + results, err := scimactions.BurstSCIMRequests(s.scimClient, 20) + require.NoError(s.T(), err) + + ok, throttled := scimactions.CountSCIMCodes(results) + require.GreaterOrEqual(s.T(), ok, 1, "at least the burst capacity should return 200 (got ok=%d, 429=%d)", ok, throttled) + require.GreaterOrEqual(s.T(), throttled, 1, "at least one request should be throttled with 429 (got ok=%d, 429=%d)", ok, throttled) +} + +func (s *SCIMOpenLDAPTestSuite) TestSCIMRL429ResponseFormat() { + subSession := s.session.NewSession() + defer subSession.Cleanup() + + logrus.Info("Verifying 429 response format (Retry-After header, scim+json content-type, SCIM error body)") + + defer func() { require.NoError(s.T(), scimactions.RestoreSCIMBaseline(s.client, s.scimClient, authactions.OpenLdap)) }() + require.NoError(s.T(), scimactions.SetSCIMConfigMap(s.client, authactions.OpenLdap, scimactions.RateLimitSCIMConfigMap(1, 1))) + + throttledResp, err := scimactions.FindFirstThrottledResponse(s.scimClient, 10) + require.NoError(s.T(), err) + require.NotNil(s.T(), throttledResp, "should observe at least one 429 within burst") + + require.Equal(s.T(), "application/scim+json", throttledResp.Header.Get("Content-Type"), "429 must use SCIM JSON content type") + retryAfter, err := scimactions.GetRetryAfterSeconds(throttledResp) + require.NoError(s.T(), err, "429 must include Retry-After header") + require.Equal(s.T(), 1, retryAfter, "Retry-After should be 1 second") + require.NoError(s.T(), scimactions.ValidateSCIMErrorBody(throttledResp, http.StatusTooManyRequests)) +} + +func (s *SCIMOpenLDAPTestSuite) TestSCIMRLTokenBucketRefillAfterRetryAfter() { + subSession := s.session.NewSession() + defer subSession.Cleanup() + + logrus.Info("Verifying token bucket refills after Retry-After period elapses") + + defer func() { require.NoError(s.T(), scimactions.RestoreSCIMBaseline(s.client, s.scimClient, authactions.OpenLdap)) }() + require.NoError(s.T(), scimactions.SetSCIMConfigMap(s.client, authactions.OpenLdap, scimactions.RateLimitSCIMConfigMap(1, 1))) + + throttledResp, err := scimactions.FindFirstThrottledResponse(s.scimClient, 10) + require.NoError(s.T(), err) + require.NotNil(s.T(), throttledResp, "should observe a 429 with Retry-After") + retryAfter, err := scimactions.GetRetryAfterSeconds(throttledResp) + require.NoError(s.T(), err) + + var resp *scimclient.Response + err = kwait.PollUntilContextTimeout(context.Background(), defaults.FiveHundredMillisecondTimeout, time.Duration(retryAfter+5)*time.Second, true, func(ctx context.Context) (bool, error) { + r, listErr := s.scimClient.Users().List(nil) + if listErr != nil { + return false, nil + } + resp = r + return r.StatusCode == http.StatusOK, nil + }) + require.NoError(s.T(), err, "request after Retry-After window should eventually return 200") + require.NoError(s.T(), scimactions.CheckStatus(resp, http.StatusOK, "request after Retry-After wait should return 200")) +} + +func (s *SCIMOpenLDAPTestSuite) TestSCIMRLAppliesToAllMethods() { + subSession := s.session.NewSession() + defer subSession.Cleanup() + + logrus.Info("Verifying rate limit applies to PATCH (non-GET method)") + + require.NoError(s.T(), scimactions.SetSCIMConfigMapAndWait(s.client, s.scimClient, authactions.OpenLdap, scimactions.BaselineSCIMConfigMap(), http.StatusOK)) + _, userID, err := scimactions.CreateSCIMUser(s.scimClient, subSession, "", true) + require.NoError(s.T(), err) + + defer func() { require.NoError(s.T(), scimactions.RestoreSCIMBaseline(s.client, s.scimClient, authactions.OpenLdap)) }() + require.NoError(s.T(), scimactions.SetSCIMConfigMap(s.client, authactions.OpenLdap, scimactions.RateLimitSCIMConfigMap(1, 1))) + + var ok, throttled int + for i := 0; i < 10; i++ { + resp, patchErr := s.scimClient.Users().Patch(userID, scimclient.PatchOp{ + Schemas: []string{scimclient.SCIMSchemaPatchOp}, + Operations: []scimclient.Operation{ + {Op: "replace", Path: "active", Value: i%2 == 0}, + }, + }) + require.NoError(s.T(), patchErr) + switch resp.StatusCode { + case http.StatusOK: + ok++ + case http.StatusTooManyRequests: + throttled++ + } + } + require.GreaterOrEqual(s.T(), throttled, 1, "rate limit should also apply to PATCH (got ok=%d, 429=%d)", ok, throttled) +} + +func (s *SCIMOpenLDAPTestSuite) TestSCIMRLDynamicConfigChange() { + subSession := s.session.NewSession() + defer subSession.Cleanup() + + logrus.Info("Verifying ConfigMap rate-limit change takes effect on next request without restart") + + defer func() { require.NoError(s.T(), scimactions.RestoreSCIMBaseline(s.client, s.scimClient, authactions.OpenLdap)) }() + require.NoError(s.T(), scimactions.SetSCIMConfigMap(s.client, authactions.OpenLdap, scimactions.RateLimitSCIMConfigMap(1, 1))) + + throttled, err := scimactions.VerifySCIMThrottle(s.scimClient, 10) + require.NoError(s.T(), err) + require.True(s.T(), throttled, "rate limit should engage at low rps before reconfig") + + require.NoError(s.T(), scimactions.SetSCIMConfigMap(s.client, authactions.OpenLdap, scimactions.RateLimitSCIMConfigMap(1000, 1000))) + require.NoError(s.T(), scimactions.WaitForSCIMEndpointStatus(s.scimClient, http.StatusOK), "endpoint should serve 200 after raising rate limit (token bucket refill)") + + results, err := scimactions.BurstSCIMRequests(s.scimClient, 20) + require.NoError(s.T(), err) + ok, _ := scimactions.CountSCIMCodes(results) + require.GreaterOrEqual(s.T(), ok, len(results)*8/10, "after raising rate limit to 1000 rps / burst 1000, the vast majority of a 20-request burst should return 200 (got ok=%d/%d)", ok, len(results)) +} + +func (s *SCIMOpenLDAPTestSuite) TestSCIMRLInvalidValuesFallback() { + subSession := s.session.NewSession() + defer subSession.Cleanup() + + logrus.Info("Verifying invalid rate-limit values fall back to defaults and do not crash") + + defer func() { require.NoError(s.T(), scimactions.RestoreSCIMBaseline(s.client, s.scimClient, authactions.OpenLdap)) }() + require.NoError(s.T(), scimactions.SetSCIMConfigMapAndWait(s.client, s.scimClient, authactions.OpenLdap, map[string]string{ + "enabled": "true", + "rateLimitRequestsPerSecond": "not-a-number", + "rateLimitBurst": "-5", + }, http.StatusOK)) + + resp, err := s.scimClient.Users().List(nil) + require.NoError(s.T(), err) + require.NoError(s.T(), scimactions.CheckStatus(resp, http.StatusOK, "SCIM should still serve requests with invalid rate-limit values")) +} + +func (s *SCIMOpenLDAPTestSuite) TestSCIMRLReDisableRateLimit() { + subSession := s.session.NewSession() + defer subSession.Cleanup() + + logrus.Info("Verifying rateLimitRequestsPerSecond=0 re-disables rate limiting") + + defer func() { require.NoError(s.T(), scimactions.RestoreSCIMBaseline(s.client, s.scimClient, authactions.OpenLdap)) }() + require.NoError(s.T(), scimactions.SetSCIMConfigMap(s.client, authactions.OpenLdap, scimactions.RateLimitSCIMConfigMap(1, 1))) + + throttled, err := scimactions.VerifySCIMThrottle(s.scimClient, 10) + require.NoError(s.T(), err) + require.True(s.T(), throttled, "rate limit should engage with low rps") + + require.NoError(s.T(), scimactions.SetSCIMConfigMap(s.client, authactions.OpenLdap, scimactions.RateLimitSCIMConfigMap(0, 0))) + + results, err := scimactions.BurstSCIMRequests(s.scimClient, 50) + require.NoError(s.T(), err) + for i, code := range results { + require.Equal(s.T(), http.StatusOK, code, "after re-disabling rate limit, request %d should return 200", i) + } +} + +func (s *SCIMOpenLDAPTestSuite) TestSCIMGroupMemberMatchingUsesPrincipalName() { + subSession := s.session.NewSession() + defer subSession.Cleanup() + + logrus.Info("Verifying group PATCH add member resolves via principal Name (not DisplayName)") + + _, userID, err := scimactions.CreateSCIMUser(s.scimClient, subSession, "", true) + require.NoError(s.T(), err) + _, groupID, err := scimactions.CreateSCIMGroup(s.scimClient, subSession, "") + require.NoError(s.T(), err) + + patchResp, err := scimactions.PatchGroup(s.scimClient, groupID, "add", "members", []scimclient.Member{{Value: userID}}) + require.NoError(s.T(), err) + require.NoError(s.T(), scimactions.CheckStatus(patchResp, http.StatusOK, "PATCH add member by principal id should return 200")) + require.NoError(s.T(), scimactions.WaitForGroupMemberCount(s.scimClient, groupID, 1)) + + getResp, err := s.scimClient.Groups().ByID(groupID) + require.NoError(s.T(), err) + require.NoError(s.T(), scimactions.CheckStatus(getResp, http.StatusOK, "GET /Groups/{id} should return 200")) + + body, err := scimactions.DecodeJSONBody(getResp) + require.NoError(s.T(), err) + members, _ := body["members"].([]interface{}) + require.Len(s.T(), members, 1, "Group should have exactly 1 member after PATCH add") + member, _ := members[0].(map[string]interface{}) + require.Equal(s.T(), userID, member["value"], "Member value should resolve to the user principal id") +} + +func (s *SCIMOpenLDAPTestSuite) TestSCIMZRegTokenRotation() { + subSession := s.session.NewSession() + defer subSession.Cleanup() + + logrus.Info("Verifying SCIM token rotation: old token rejected, new token works") + + oldToken, err := scimactions.FetchSCIMBearerToken(s.client, authactions.OpenLdap) + require.NoError(s.T(), err, "Should fetch existing SCIM token") + require.NotEmpty(s.T(), oldToken) + + clusterContext, err := s.client.WranglerContext.Core.Secret().List(scimactions.SCIMSecretNamespace, metav1.ListOptions{ + LabelSelector: fmt.Sprintf("cattle.io/kind=scim-auth-token,authn.management.cattle.io/provider=%s", authactions.OpenLdap), + }) + require.NoError(s.T(), err) + require.NotEmpty(s.T(), clusterContext.Items, "Should find SCIM token secret") + + for i := range clusterContext.Items { + err = s.client.WranglerContext.Core.Secret().Delete(scimactions.SCIMSecretNamespace, clusterContext.Items[i].Name, &metav1.DeleteOptions{}) + require.NoError(s.T(), err, "Should delete old SCIM token secret") + } + + newToken, err := scimactions.CreateSCIMTokenSecret(s.client, authactions.OpenLdap) + require.NoError(s.T(), err, "Should create a new SCIM token") + require.NotEqual(s.T(), oldToken, newToken, "New token must differ from old") + + defer func() { + s.scimClient = scimactions.NewSCIMClientWithToken(s.client.RancherConfig.Host, authactions.OpenLdap, newToken) + }() + + oldClient := scimactions.NewSCIMClientWithToken(s.client.RancherConfig.Host, authactions.OpenLdap, oldToken) + oldResp, err := oldClient.Users().List(nil) + require.NoError(s.T(), err) + require.NoError(s.T(), scimactions.CheckStatus(oldResp, http.StatusUnauthorized, "old SCIM token should be rejected with 401")) + + newClient := scimactions.NewSCIMClientWithToken(s.client.RancherConfig.Host, authactions.OpenLdap, newToken) + newResp, err := newClient.Users().List(nil) + require.NoError(s.T(), err) + require.NoError(s.T(), scimactions.CheckStatus(newResp, http.StatusOK, "new SCIM token should be accepted with 200")) +} + +func (s *SCIMOpenLDAPTestSuite) TestSCIMRegConfigMapKeyParsingNonInterference() { + subSession := s.session.NewSession() + defer subSession.Cleanup() + + logrus.Info("Verifying rate-limit ConfigMap keys do not interfere with parsing of enabled/paused/userIdAttribute") + + defer func() { require.NoError(s.T(), scimactions.RestoreSCIMBaseline(s.client, s.scimClient, authactions.OpenLdap)) }() + require.NoError(s.T(), scimactions.SetSCIMConfigMapAndWait(s.client, s.scimClient, authactions.OpenLdap, map[string]string{ + "enabled": "true", + "paused": "false", + "userIdAttribute": "externalId", + "rateLimitRequestsPerSecond": "1000", + "rateLimitBurst": "1000", + }, http.StatusOK)) + + externalID := namegen.AppendRandomString("ext-noninterference") + _, userID, err := scimactions.CreateSCIMUser(s.scimClient, subSession, externalID, true) + require.NoError(s.T(), err) + + mgmtUser, err := s.client.Management.User.ByID(userID) + require.NoError(s.T(), err) + found := false + for _, pid := range mgmtUser.PrincipalIDs { + if strings.Contains(pid, externalID) { + found = true + break + } + } + require.True(s.T(), found, "userIdAttribute=externalId should still apply when rate-limit keys are also set") +} + +func (s *SCIMOpenLDAPTestSuite) TestSCIMPostReprovisionsDisabledUser() { + subSession := s.session.NewSession() + defer subSession.Cleanup() + + logrus.Info("Verifying POST with externalId matching a disabled user re-provisions and returns 200") + + defer func() { require.NoError(s.T(), scimactions.RestoreSCIMBaseline(s.client, s.scimClient, authactions.OpenLdap)) }() + require.NoError(s.T(), scimactions.SetSCIMConfigMapAndWait(s.client, s.scimClient, authactions.OpenLdap, map[string]string{"enabled": "true", "userIdAttribute": "externalId"}, http.StatusOK)) + + externalID := namegen.AppendRandomString("ext-reprovision") + _, userID, err := scimactions.CreateSCIMUser(s.scimClient, subSession, externalID, true) + require.NoError(s.T(), err) + + patchResp, err := s.scimClient.Users().Patch(userID, scimclient.PatchOp{ + Schemas: []string{scimclient.SCIMSchemaPatchOp}, + Operations: []scimclient.Operation{ + {Op: "replace", Path: "active", Value: false}, + }, + }) + require.NoError(s.T(), err) + require.NoError(s.T(), scimactions.CheckStatus(patchResp, http.StatusOK, "PATCH active=false should return 200")) + + resp, err := s.scimClient.Users().Create(scimclient.User{ + Schemas: []string{scimclient.SCIMSchemaUser}, + UserName: namegen.AppendRandomString("reprov-user"), + ExternalID: externalID, + Active: scimclient.BoolPtr(true), + }) + require.NoError(s.T(), err) + require.NoError(s.T(), scimactions.CheckStatus(resp, http.StatusOK, "POST with externalId of disabled user should return 200 (re-provision)")) + + body, err := scimactions.DecodeJSONBody(resp) + require.NoError(s.T(), err) + require.Equal(s.T(), userID, body["id"], "re-provisioned user should have the same id as the original (not a duplicate)") + require.Equal(s.T(), true, body["active"], "re-provisioned user should be active") + require.Equal(s.T(), externalID, body["externalId"], "externalId should match") +} + +func (s *SCIMOpenLDAPTestSuite) TestSCIMPostDuplicateActiveExternalIDReturns409() { + subSession := s.session.NewSession() + defer subSession.Cleanup() + + logrus.Info("Verifying POST with externalId matching an active user returns 409") + + defer func() { require.NoError(s.T(), scimactions.RestoreSCIMBaseline(s.client, s.scimClient, authactions.OpenLdap)) }() + require.NoError(s.T(), scimactions.SetSCIMConfigMapAndWait(s.client, s.scimClient, authactions.OpenLdap, map[string]string{"enabled": "true", "userIdAttribute": "externalId"}, http.StatusOK)) + + externalID := namegen.AppendRandomString("ext-dup-active") + _, _, err := scimactions.CreateSCIMUser(s.scimClient, subSession, externalID, true) + require.NoError(s.T(), err) + + resp, err := s.scimClient.Users().Create(scimclient.User{ + Schemas: []string{scimclient.SCIMSchemaUser}, + UserName: namegen.AppendRandomString("dup-user"), + ExternalID: externalID, + Active: scimclient.BoolPtr(true), + }) + require.NoError(s.T(), err) + require.NoError(s.T(), scimactions.CheckStatus(resp, http.StatusConflict, "POST with externalId of active user should return 409")) +} + +func (s *SCIMOpenLDAPTestSuite) TestSCIMPutUserNameImmutableReturns400() { + subSession := s.session.NewSession() + defer subSession.Cleanup() + + logrus.Info("Verifying PUT with changed userName returns 400 with scimType mutability") + + _, userID, err := scimactions.CreateSCIMUser(s.scimClient, subSession, "", false) + require.NoError(s.T(), err) + + resp, err := s.scimClient.Users().Update(userID, scimclient.User{ + Schemas: []string{scimclient.SCIMSchemaUser}, + UserName: namegen.AppendRandomString("renamed-put"), + }) + require.NoError(s.T(), err) + require.NoError(s.T(), scimactions.CheckStatus(resp, http.StatusBadRequest, "PUT with changed userName should return 400")) + + body, err := scimactions.DecodeJSONBody(resp) + require.NoError(s.T(), err) + require.Equal(s.T(), "mutability", body["scimType"], "error body should have scimType=mutability") +} + +func (s *SCIMOpenLDAPTestSuite) TestSCIMPatchUserNameMutabilityError() { + subSession := s.session.NewSession() + defer subSession.Cleanup() + + logrus.Info("Verifying PATCH op:replace userName returns 400 with scimType mutability when userName is principal") + + _, userID, err := scimactions.CreateSCIMUser(s.scimClient, subSession, "", false) + require.NoError(s.T(), err) + + resp, err := s.scimClient.Users().Patch(userID, scimclient.PatchOp{ + Schemas: []string{scimclient.SCIMSchemaPatchOp}, + Operations: []scimclient.Operation{ + {Op: "replace", Path: "userName", Value: namegen.AppendRandomString("renamed-patch")}, + }, + }) + require.NoError(s.T(), err) + require.NoError(s.T(), scimactions.CheckStatus(resp, http.StatusBadRequest, "PATCH op:replace userName should return 400")) + + body, err := scimactions.DecodeJSONBody(resp) + require.NoError(s.T(), err) + require.Equal(s.T(), "mutability", body["scimType"], "error body should have scimType=mutability") +} + +func (s *SCIMOpenLDAPTestSuite) TestSCIMPatchDeactivateAdminReturns409() { + subSession := s.session.NewSession() + defer subSession.Cleanup() + + logrus.Info("Verifying PATCH active=false on default admin returns 409") + + adminUsers, err := s.client.Management.User.List(&types.ListOpts{ + Filters: map[string]interface{}{"username": "admin"}, + }) + require.NoError(s.T(), err) + require.NotEmpty(s.T(), adminUsers.Data, "default admin user should exist") + + resp, err := s.scimClient.Users().Patch(adminUsers.Data[0].ID, scimclient.PatchOp{ + Schemas: []string{scimclient.SCIMSchemaPatchOp}, + Operations: []scimclient.Operation{ + {Op: "replace", Path: "active", Value: false}, + }, + }) + require.NoError(s.T(), err) + require.NoError(s.T(), scimactions.CheckStatus(resp, http.StatusConflict, "PATCH active=false on default admin should return 409")) +} + func TestSCIMOpenLDAPSuite(t *testing.T) { suite.Run(t, new(SCIMOpenLDAPTestSuite)) }