diff --git a/Makefile b/Makefile index 2d0fdf6..568de01 100644 --- a/Makefile +++ b/Makefile @@ -57,7 +57,7 @@ clean: ## Cleans up the generated resources .PHONY: run run: manifests generate fmt vet ## Run a controller from your host. - go run ./main.go + go run ./main.go $(RUN_ARGS) ### ### Assets diff --git a/README.md b/README.md index ca96e13..1a48b0b 100644 --- a/README.md +++ b/README.md @@ -74,3 +74,23 @@ make manifests **NOTE:** Run `make --help` for more information on all potential `make` targets More information can be found via the [Kubebuilder Documentation](https://book.kubebuilder.io/introduction.html) + +## VSHN Development Infrastructure + +For a full E2E development setup the VSHN infrastructure has you covered. + +1. Create your isolated realm on the testing Keycloak . +2. Create a user for the controller in the admin realm . +Configure as follows: +![User Details](./docs/keycloak-config/user-details.png "User Details") +![User Password](./docs/keycloak-config/user-pw.png "User Password") +![User Roles](./docs/keycloak-config/user-roles.png "User Roles") +3. [Sign in](https://kb.vshn.ch/corp-tech/projectsyn/how-tos/connect-to-lieutenant-clusters.html) to the `lieutenant-dev` cluster at . +4. Run the controller with the following command: +```sh +REALM=your-realm-name +KEYCLOAK_USER=your-controller-user +KEYCLOAK_PASSWORD=your-controller-password + +make RUN_ARGS="--keycloak-realm=$REALM --keycloak-base-url=https://id.test.vshn.net --keycloak-user=$KEYCLOAK_USER --keycloak-password=$KEYCLOAK_PASSWORD --keycloak-login-realm=master --keycloak-legacy-wildfly-support=true --vault-address=https://vault-dev.syn.vshn.net/ --vault-token=$(kubectl create token -n lieutenant lieutenant-keycloak-idp-controller)" run +``` diff --git a/config/default/kustomization.yaml b/config/default/kustomization.yaml index 0e0ea0f..dc292be 100644 --- a/config/default/kustomization.yaml +++ b/config/default/kustomization.yaml @@ -40,6 +40,7 @@ kind: Kustomization resources: - ../rbac - ../manager -- ../prometheus +# We deploy lieutenant in its own vCluster and do not have a Prometheus operator there +# - ../prometheus patches: - path: manager_auth_proxy_patch.yaml diff --git a/config/default/manager_auth_proxy_patch.yaml b/config/default/manager_auth_proxy_patch.yaml index b65cf22..b751266 100644 --- a/config/default/manager_auth_proxy_patch.yaml +++ b/config/default/manager_auth_proxy_patch.yaml @@ -53,4 +53,3 @@ spec: - "--health-probe-bind-address=:8081" - "--metrics-bind-address=127.0.0.1:8080" - "--leader-elect" - - "--namespace=$(POD_NAMESPACE)" diff --git a/config/manager/manager.yaml b/config/manager/manager.yaml index 50b2913..ad7de11 100644 --- a/config/manager/manager.yaml +++ b/config/manager/manager.yaml @@ -43,7 +43,6 @@ spec: containers: - args: - --leader-elect - - --namespace=$(POD_NAMESPACE) env: - name: POD_NAMESPACE valueFrom: diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml index 2d26082..196ac4b 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/role.yaml @@ -11,6 +11,8 @@ rules: verbs: - get - list + - patch + - update - watch - apiGroups: - syn.tools diff --git a/controllers/cluster_controller.go b/controllers/cluster_controller.go index 6dfb540..7c52fef 100644 --- a/controllers/cluster_controller.go +++ b/controllers/cluster_controller.go @@ -2,17 +2,35 @@ package controllers import ( "context" + _ "embed" + "errors" "fmt" + "net/http" + "path" + "slices" + "strings" "time" + "github.com/Nerzal/gocloak/v13" + "github.com/google/go-jsonnet" + "github.com/hashicorp/vault-client-go" + "github.com/hashicorp/vault-client-go/schema" lieutenantv1alpha1 "github.com/projectsyn/lieutenant-operator/api/v1alpha1" + "github.com/wI2L/jsondiff" + "go.uber.org/multierr" + "golang.org/x/oauth2" apierrors "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/util/json" + "k8s.io/utils/ptr" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" "sigs.k8s.io/controller-runtime/pkg/log" ) +const finalizerName = "syn.tools/lieutenant-keycloak-idp-controller" + type Clock interface { Now() time.Time } @@ -21,25 +39,149 @@ type Clock interface { type ClusterReconciler struct { client.Client Scheme *runtime.Scheme + + VaultTokenSource func() (*oauth2.Token, error) + VaultAuthClient VaultPartialAuthClient + VaultSecretsClient VaultPartialSecretsClient + VaultRole string + VaultLoginMountPath string + VaultKvPath string + + KeycloakClient PartialKeycloakClient + KeycloakRealm string + KeycloakLoginRealm string + KeycloakUser string + KeycloakPassword string + + ClientTemplateFile string + ClientRoleMappingTemplateFile string + JsonnetImportPaths []string + + KeycloakClientIgnorePaths []string } -//+kubebuilder:rbac:groups=syn.tools,resources=clusters,verbs=get;list;watch +//+kubebuilder:rbac:groups=syn.tools,resources=clusters,verbs=get;list;watch;update;patch //+kubebuilder:rbac:groups=syn.tools,resources=clusters/status,verbs=get;update;patch //+kubebuilder:rbac:groups=syn.tools,resources=clusters/finalizers,verbs=update // Reconcile reconciles the Cluster resource. -func (r *ClusterReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { +func (r *ClusterReconciler) Reconcile(ctx context.Context, req ctrl.Request) (res ctrl.Result, err error) { l := log.FromContext(ctx).WithName("ClusterReconciler.Reconcile") instance := &lieutenantv1alpha1.Cluster{} - err := r.Get(ctx, req.NamespacedName, instance) - if err != nil { + if err := r.Get(ctx, req.NamespacedName, instance); err != nil { if apierrors.IsNotFound(err) { l.Info("Cluster resource not found. Ignoring since object must be deleted.") return ctrl.Result{}, nil } return ctrl.Result{}, fmt.Errorf("unable to get Cluster resource: %w", err) } + if instance.DeletionTimestamp != nil { + if controllerutil.ContainsFinalizer(instance, finalizerName) { + if err := r.cleanupClient(ctx, instance); err != nil { + return ctrl.Result{}, fmt.Errorf("unable to cleanup client: %w", err) + } + controllerutil.RemoveFinalizer(instance, finalizerName) + return ctrl.Result{}, r.Update(ctx, instance) + } + return ctrl.Result{}, nil + } + if updated := controllerutil.AddFinalizer(instance, finalizerName); updated { + return ctrl.Result{Requeue: true}, r.Update(ctx, instance) + } + + token, err := r.keycloakLogin(ctx) + if err != nil { + return ctrl.Result{}, fmt.Errorf("unable to login to keycloak: %w", err) + } + defer func() { + if logoutErr := r.keycloakLogout(ctx, token); logoutErr != nil { + multierr.AppendInto(&err, fmt.Errorf("unable to logout from keycloak: %w", logoutErr)) + } + }() + + jvm, err := r.jsonnetVMWithContext(instance) + if err != nil { + return ctrl.Result{}, fmt.Errorf("unable to create jsonnet vm: %w", err) + } + + // Create or updated client + templatedClient, err := r.templateKeycloakClient(jvm) + if err != nil { + return ctrl.Result{}, fmt.Errorf("unable to template keycloak client: %w", err) + } + + client, err := r.findClientByClientId(ctx, token.AccessToken, r.KeycloakRealm, *templatedClient.ClientID) + if err != nil { + return ctrl.Result{}, fmt.Errorf("unable to get client: %w", err) + } + if client == nil { + l.Info("Client not found, creating", "client", templatedClient) + id, err := r.KeycloakClient.CreateClient(ctx, token.AccessToken, r.KeycloakRealm, templatedClient) + if err != nil { + return ctrl.Result{}, fmt.Errorf("unable to create client: %w", err) + } + l.Info("Client created, requeuing", "id", id) + return ctrl.Result{Requeue: true}, nil + } + + l.Info("Client found, updating", "client", client.ID) + templatedClient.ID = client.ID + + ignores := append([]string{ + "/secret", + "/attributes/client.secret.creation.time", + }, r.KeycloakClientIgnorePaths...) + patch, err := jsondiff.Compare(client, templatedClient, jsondiff.Ignores(ignores...)) + if err != nil { + return ctrl.Result{}, fmt.Errorf("unable to compare existing and templated clients: %w", err) + } + if len(patch) == 0 { + l.Info("No changes to the client detected") + } else { + l.Info("Updating client", "changes", patch) + if err := r.KeycloakClient.UpdateClient(ctx, token.AccessToken, r.KeycloakRealm, templatedClient); err != nil { + return ctrl.Result{}, fmt.Errorf("unable to update client: %w", err) + } + } + + client, err = r.findClientByClientId(ctx, token.AccessToken, r.KeycloakRealm, *templatedClient.ClientID) + if err != nil { + return ctrl.Result{}, fmt.Errorf("unable to get client after creation or updating: %w", err) + } + if client == nil { + return ctrl.Result{}, fmt.Errorf("client %q not found after creation or updating", *templatedClient.ClientID) + } + + // Vault secret + if client.Secret == nil || *client.Secret == "" { + return ctrl.Result{}, fmt.Errorf("client %q has no secret", *templatedClient.ClientID) + } + if err := r.syncVaultSecret(ctx, instance, *client.Secret); err != nil { + return ctrl.Result{}, fmt.Errorf("unable to sync vault secret: %w", err) + } + + // template client roles + rolesRaw, err := jvm.EvaluateFile(r.ClientRoleMappingTemplateFile) + if err != nil { + return ctrl.Result{}, fmt.Errorf("unable to evaluate client-roles jsonnet: %w", err) + } + var templatedRoles []roleMapping + if err := json.Unmarshal([]byte(rolesRaw), &templatedRoles); err != nil { + return ctrl.Result{}, fmt.Errorf("unable to unmarshal client-roles jsonnet result: %w", err) + } + slices.SortFunc(templatedRoles, func(a, b roleMapping) int { + return strings.Compare(a.Role, b.Role)*10 + strings.Compare(a.Group, b.Group) + }) + templatedRoles = slices.Compact(templatedRoles) + + if err := r.syncClientRoles(ctx, token.AccessToken, *client.ID, templatedRoles); err != nil { + return ctrl.Result{}, fmt.Errorf("unable to sync client roles: %w", err) + } + + if err := r.syncClientRoleGroupMappings(ctx, token.AccessToken, *client.ID, templatedRoles); err != nil { + return ctrl.Result{}, fmt.Errorf("unable to sync client role group mappings: %w", err) + } return ctrl.Result{}, nil } @@ -50,3 +192,308 @@ func (r *ClusterReconciler) SetupWithManager(mgr ctrl.Manager) error { For(&lieutenantv1alpha1.Cluster{}). Complete(r) } + +func (r *ClusterReconciler) cleanupClient(ctx context.Context, instance *lieutenantv1alpha1.Cluster) (err error) { + l := log.FromContext(ctx).WithName("ClusterReconciler.cleanup") + + jvm, err := r.jsonnetVMWithContext(instance) + if err != nil { + return fmt.Errorf("unable to create jsonnet vm: %w", err) + } + + // call into jsonnet to get the templated client id + templatedClient, err := r.templateKeycloakClient(jvm) + if err != nil { + return fmt.Errorf("unable to template keycloak client: %w", err) + } + + token, err := r.keycloakLogin(ctx) + if err != nil { + return fmt.Errorf("unable to login to keycloak: %w", err) + } + defer func() { + if logoutErr := r.keycloakLogout(ctx, token); logoutErr != nil { + multierr.AppendInto(&err, fmt.Errorf("unable to logout from keycloak: %w", logoutErr)) + } + }() + + client, err := r.findClientByClientId(ctx, token.AccessToken, r.KeycloakRealm, *templatedClient.ClientID) + if err != nil { + return fmt.Errorf("unable to get client: %w", err) + } + if client == nil { + l.Info("Client not found, skipping cleanup") + return nil + } + l.Info("Client found, deleting", "client", client.ID) + if err := r.KeycloakClient.DeleteClient(ctx, token.AccessToken, r.KeycloakRealm, *client.ID); err != nil { + return fmt.Errorf("unable to delete client: %w", err) + } + + // delete vault secret + tokenAuth, err := r.vaultRequestToken(ctx) + if err != nil { + return fmt.Errorf("unable to login to vault: %w", err) + } + secretPath := vaultSecretPath(instance) + mountPath := vault.WithMountPath(r.VaultKvPath) + if _, err := r.VaultSecretsClient.KvV2Delete(ctx, secretPath, mountPath, tokenAuth); err != nil { + return fmt.Errorf("unable to delete vault secret: %w", err) + } + + return nil +} + +// syncClientRoles creates the given client roles if they do not exist yet +// and deletes all roles that are not in the given list. +func (r *ClusterReconciler) syncClientRoles(ctx context.Context, token string, clientId string, roles []roleMapping) error { + l := log.FromContext(ctx).WithName("ClusterReconciler.syncClientRoles") + + var clientRoles []string + for _, role := range roles { + clientRoles = append(clientRoles, role.Role) + } + slices.Sort(clientRoles) + clientRoles = slices.Compact(clientRoles) + for _, role := range clientRoles { + role := role + id, err := r.KeycloakClient.CreateClientRole(ctx, token, r.KeycloakRealm, clientId, gocloak.Role{ + Name: &role, + }) + if err != nil { + var kcErr *gocloak.APIError + if errors.As(err, &kcErr) && kcErr.Code == http.StatusConflict { + l.Info("Client role already exists", "role", role) + continue + } + l.Error(err, "unable to create client role", "role", role) + return fmt.Errorf("keycloak error: %w", err) + } + l.Info("Client role created", "role", role, "id", id) + } + + actualRoles, err := r.KeycloakClient.GetClientRoles(ctx, token, r.KeycloakRealm, clientId, gocloak.GetRoleParams{ + Max: ptr.To(-1), + }) + if err != nil { + return fmt.Errorf("unable to get client roles: %w", err) + } + + for _, role := range actualRoles { + role := role + if slices.Contains(clientRoles, *role.Name) { + continue + } + l.Info("Deleting client role", "role", *role.Name, "id", *role.ID) + if err := r.KeycloakClient.DeleteClientRole(ctx, token, r.KeycloakRealm, clientId, *role.Name); err != nil { + return fmt.Errorf("unable to delete client role: %w", err) + } + } + + return nil +} + +// syncClientRoleGroupMappings creates the given client role group mappings if they do not exist yet +// and deletes all mappings that are not in the given list. +func (r *ClusterReconciler) syncClientRoleGroupMappings(ctx context.Context, token string, clientId string, roles []roleMapping) error { + l := log.FromContext(ctx).WithName("ClusterReconciler.syncClientRoleGroupMappings") + + actualRoles, err := r.KeycloakClient.GetClientRoles(ctx, token, r.KeycloakRealm, clientId, gocloak.GetRoleParams{ + Max: ptr.To(-1), + }) + if err != nil { + return fmt.Errorf("unable to get client roles: %w", err) + } + + groups := make(map[string][]gocloak.Role) + for _, role := range roles { + if role.Group == "" { + continue + } + ri := slices.IndexFunc(actualRoles, func(r *gocloak.Role) bool { + return *r.Name == role.Role + }) + if ri == -1 { + return fmt.Errorf("unable to find role %q", role.Role) + } + groups[role.Group] = append(groups[role.Group], *actualRoles[ri]) + } + for groupPath, roles := range groups { + if len(roles) == 0 { + l.Info("Group has no roles to map, skipping", "group", groupPath) + continue + } + + g, err := r.KeycloakClient.GetGroupByPath(ctx, token, r.KeycloakRealm, groupPath) + if err != nil { + var kcErr *gocloak.APIError + if errors.As(err, &kcErr) && kcErr.Code == http.StatusNotFound { + l.Info("Group not found, skipping mapping", "group", groupPath) + continue + } + return fmt.Errorf("unable to get group: %w", err) + } + + l.Info("Syncing client role group mapping", "group", groupPath, "roles", roles) + if err := r.KeycloakClient.AddClientRolesToGroup(ctx, token, r.KeycloakRealm, clientId, *g.ID, roles); err != nil { + return fmt.Errorf("unable to add client roles to group: %w", err) + } + } + + l.Info("Looking for client role group mappings to delete") + + for _, role := range actualRoles { + groups, err := r.KeycloakClient.GetGroupsByClientRole(ctx, token, r.KeycloakRealm, *role.Name, clientId) + if err != nil { + return fmt.Errorf("unable to get groups by client role: %w", err) + } + for _, group := range groups { + if slices.ContainsFunc(roles, func(r roleMapping) bool { return r.Group == *group.Path }) { + continue + } + l.Info("Deleting client role group mapping", "role", *role.Name, "group", *group.Path) + if err := r.KeycloakClient.DeleteClientRoleFromGroup(ctx, token, r.KeycloakRealm, clientId, *group.ID, []gocloak.Role{*role}); err != nil { + return fmt.Errorf("unable to delete client role: %w", err) + } + } + } + + return nil +} + +// findClientByClientId returns the client with the given client id or nil if no client was found +func (r *ClusterReconciler) findClientByClientId(ctx context.Context, token string, realm string, clientId string) (*gocloak.Client, error) { + clients, err := r.KeycloakClient.GetClients( + ctx, + token, + realm, + gocloak.GetClientsParams{ + ClientID: &clientId, + Max: ptr.To(-1), + }, + ) + if err != nil { + return nil, fmt.Errorf("unable to get clients: %w", err) + } + // Since we are filtering by client id, which is unique, there should only be one client + for _, client := range clients { + return client, nil + } + + return nil, nil +} + +func (r *ClusterReconciler) jsonnetVMWithContext(instance *lieutenantv1alpha1.Cluster) (*jsonnet.VM, error) { + jcr, err := json.Marshal(map[string]any{ + "cluster": instance, + }) + if err != nil { + return nil, fmt.Errorf("unable to marshal jsonnet context: %w", err) + } + jvm := jsonnet.MakeVM() + jvm.ExtCode("context", string(jcr)) + jvm.Importer(&jsonnet.FileImporter{ + JPaths: r.JsonnetImportPaths, + }) + return jvm, nil +} + +func (r *ClusterReconciler) templateKeycloakClient(jvm *jsonnet.VM) (gocloak.Client, error) { + cRaw, err := jvm.EvaluateFile(r.ClientTemplateFile) + if err != nil { + return gocloak.Client{}, fmt.Errorf("unable to evaluate jsonnet: %w", err) + } + var c gocloak.Client + if err := json.Unmarshal([]byte(cRaw), &c); err != nil { + return c, fmt.Errorf("unable to unmarshal `cluster` jsonnet result: %w", err) + } + if c.ClientID == nil || *c.ClientID == "" { + return c, fmt.Errorf("invalid cluster template: `clientId` is empty") + } + return c, nil +} + +func (r *ClusterReconciler) vaultRequestToken(ctx context.Context) (vault.RequestOption, error) { + vt, err := r.VaultTokenSource() + if err != nil { + return nil, fmt.Errorf("unable to get vault token: %w", err) + } + tres, err := r.VaultAuthClient.KubernetesLogin( + ctx, + schema.KubernetesLoginRequest{Jwt: vt.AccessToken, Role: r.VaultRole}, + vault.WithMountPath(r.VaultLoginMountPath), + ) + if err != nil { + return nil, fmt.Errorf("unable to login to vault: %w", err) + } + return vault.WithToken(tres.Auth.ClientToken), nil +} + +func vaultSecretPath(instance *lieutenantv1alpha1.Cluster) string { + return path.Join(instance.Spec.TenantRef.Name, instance.Name, "keycloak", "oidcClient") +} + +func (r *ClusterReconciler) syncVaultSecret(ctx context.Context, instance *lieutenantv1alpha1.Cluster, secret string) error { + l := log.FromContext(ctx).WithName("ClusterReconciler.syncVaultSecret") + + tokenAuth, err := r.vaultRequestToken(ctx) + if err != nil { + return fmt.Errorf("unable to login to vault: %w", err) + } + secretPath := vaultSecretPath(instance) + mountPath := vault.WithMountPath(r.VaultKvPath) + + var existingSecret string + res, err := r.VaultSecretsClient.KvV2Read(ctx, secretPath, mountPath, tokenAuth) + if err != nil && !vault.IsErrorStatus(err, http.StatusNotFound) { + return fmt.Errorf("unable to read vault secret: %w", err) + } + if res != nil && res.Data.Data != nil { + existingSecret, _ = res.Data.Data["secret"].(string) + } + if existingSecret == "" { + l.Info("No vault secret found") + } + if existingSecret == secret { + l.Info("Vault secret is up to date") + return nil + } + + l.Info("Updating vault secret") + _, err = r.VaultSecretsClient.KvV2Write( + ctx, + secretPath, + schema.KvV2WriteRequest{ + Data: map[string]any{ + "secret": secret, + }, + }, + mountPath, + tokenAuth, + ) + if err != nil { + return fmt.Errorf("unable to write vault secret: %w", err) + } + + return nil +} + +func (r *ClusterReconciler) keycloakLogin(ctx context.Context) (*gocloak.JWT, error) { + return r.KeycloakClient.LoginAdmin(ctx, r.KeycloakUser, r.KeycloakPassword, r.loginRealm()) +} + +func (r *ClusterReconciler) keycloakLogout(ctx context.Context, token *gocloak.JWT) error { + return r.KeycloakClient.LogoutPublicClient(ctx, "admin-cli", r.loginRealm(), token.AccessToken, token.RefreshToken) +} + +func (r *ClusterReconciler) loginRealm() string { + if r.KeycloakLoginRealm != "" { + return r.KeycloakLoginRealm + } + return r.KeycloakRealm +} + +type roleMapping struct { + Role string `json:"role"` + Group string `json:"group"` +} diff --git a/controllers/cluster_controller_test.go b/controllers/cluster_controller_test.go index ac36c6c..fa95bcd 100644 --- a/controllers/cluster_controller_test.go +++ b/controllers/cluster_controller_test.go @@ -2,21 +2,52 @@ package controllers import ( "context" + "crypto/md5" + "fmt" + "net/http" + "os" + "path" + "slices" "testing" + "github.com/Nerzal/gocloak/v13" "github.com/go-logr/logr/testr" + "github.com/hashicorp/vault-client-go" + "github.com/hashicorp/vault-client-go/schema" lieutenantv1alpha1 "github.com/projectsyn/lieutenant-operator/api/v1alpha1" "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + "go.uber.org/multierr" + "golang.org/x/exp/maps" + "golang.org/x/oauth2" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" clientgoscheme "k8s.io/client-go/kubernetes/scheme" + "k8s.io/utils/ptr" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/client/fake" "sigs.k8s.io/controller-runtime/pkg/log" "sigs.k8s.io/controller-runtime/pkg/reconcile" + + "github.com/projectsyn/lieutenant-keycloak-idp-controller/controllers/mock" + "github.com/projectsyn/lieutenant-keycloak-idp-controller/controllers/testtemplates" +) + +const ( + vaultAccessToken = "vaultAccessToken" + vaultToken = "vaultToken" + vaultRole = "lieutenant-keycloak-idp-controller" + + keycloakLoginRealm = "admin" + keycloakRealm = "testrealm" + keycloakAccessToken = "accessToken" ) -func Test_ClusterReconciler_Reconcile(t *testing.T) { +var ( + rolesInTemplate = []string{"restricted-access", "openshiftroot", "openshiftrootswissonly"} +) + +func Test_ClusterReconciler_Reconcile_AddFinalizer(t *testing.T) { ctx := log.IntoContext(context.Background(), testr.New(t)) cl := &lieutenantv1alpha1.Cluster{ @@ -34,6 +65,404 @@ func Test_ClusterReconciler_Reconcile(t *testing.T) { } _, err := subject.Reconcile(ctx, reconcile.Request{NamespacedName: client.ObjectKeyFromObject(cl)}) require.NoError(t, err) + + require.NoError(t, c.Get(ctx, client.ObjectKeyFromObject(cl), cl)) + require.Contains(t, cl.Finalizers, finalizerName) +} + +func Test_ClusterReconciler_Reconcile_E2E(t *testing.T) { + ctx := log.IntoContext(context.Background(), testr.New(t)) + tpld := t.TempDir() + require.NoError(t, os.WriteFile(path.Join(tpld, "client.jsonnet"), []byte(testtemplates.Client), 0644)) + require.NoError(t, os.WriteFile(path.Join(tpld, "crm.jsonnet"), []byte(testtemplates.ClientRoles), 0644)) + + mctrl := gomock.NewController(t) + defer mctrl.Finish() + + mockKeycloak := mock.NewMockPartialKeycloakClient(mctrl) + mockVaultAuth := mock.NewMockVaultPartialAuthClient(mctrl) + mockVaultSecrets := mock.NewMockVaultPartialSecretsClient(mctrl) + + mockKeycloakLogin(mockKeycloak, keycloakLoginRealm) + mockVaultLogin(mockVaultAuth) + tkco := trackKeycloakObjects(mockKeycloak) + tks := trackVaultSecretCreation(mockVaultSecrets) + + cluster := &lieutenantv1alpha1.Cluster{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + Namespace: "test", + Finalizers: []string{finalizerName}, + }, + Spec: lieutenantv1alpha1.ClusterSpec{ + DisplayName: "test", + }, + } + + c := fakeClient(t, cluster) + + tkco.groups = append(tkco.groups, gocloak.Group{ + ID: ptr.To("b45ec6e5-edfc-4823-93f0-9f3012a42f64"), + Path: ptr.To("/LDAP/VSHN openshiftroot"), + }, gocloak.Group{ + ID: ptr.To("10f810e0-54b8-45af-bbc4-a721d34be7e4"), + Path: ptr.To("/LDAP/VSHN AdditionalGroupOfUsers"), + }) + + subject := &ClusterReconciler{ + Client: c, + Scheme: c.Scheme(), + + KeycloakLoginRealm: keycloakLoginRealm, + KeycloakRealm: keycloakRealm, + + KeycloakClient: mockKeycloak, + + VaultTokenSource: vaultTokenSource, + VaultAuthClient: mockVaultAuth, + VaultSecretsClient: mockVaultSecrets, + + ClientTemplateFile: path.Join(tpld, "client.jsonnet"), + ClientRoleMappingTemplateFile: path.Join(tpld, "crm.jsonnet"), + + KeycloakClientIgnorePaths: []string{"/attributes/ignored"}, + } + + require.NoError(t, + reconcileNTimes(ctx, subject, reconcile.Request{NamespacedName: client.ObjectKeyFromObject(cluster)}, 5)) + + t.Run("CreateClientAndSecret", func(t *testing.T) { + require.Len(t, tkco.clients, 1, "should have created a client") + createdClient := tkco.clients[0] + require.Equal(t, "cluster_test", *createdClient.ClientID, "should have created client with correct name") + + requireClientRolesForClient(t, tkco, *createdClient.ID, rolesInTemplate...) + + require.Len(t, tkco.clientRolesToGroupsMapping, 1) + require.ElementsMatch(t, + []string{"openshiftroot"}, + tkco.clientRolesToGroupsMapping[clientGroupMappingKey{clientId: *createdClient.ID, groupId: *tkco.groups[0].ID}], + "should add mapping to referenced group") + + sk := "test/keycloak/oidcClient" + require.ElementsMatch(t, + []string{sk}, + maps.Keys(tks.secrets), + "should have created a secret") + + require.Equal(t, + tks.secrets[sk], + map[string]any{"secret": md5sum(*createdClient.ClientID)}, + "should have created a secret with the secret returned by keycloak") + }) + + t.Run("UpdateClient", func(t *testing.T) { + createdClient := tkco.clients[0] + require.Equal(t, map[string]string{"custom": "attribute", "ignored": "attribute"}, *createdClient.Attributes, "should have attributes from template") + createdClient.Attributes = &map[string]string{"custom": "attribute", "new": "attribute"} + _, err := subject.Reconcile(ctx, reconcile.Request{NamespacedName: client.ObjectKeyFromObject(cluster)}) + require.NoError(t, err) + + updatedClient := tkco.clients[0] + require.Equal(t, map[string]string{"custom": "attribute", "ignored": "attribute"}, *updatedClient.Attributes, "should have attributes from template") + + createdClient.Attributes = &map[string]string{"custom": "attribute", "ignored": "changed"} + _, err = subject.Reconcile(ctx, reconcile.Request{NamespacedName: client.ObjectKeyFromObject(cluster)}) + require.NoError(t, err) + + updatedClient = tkco.clients[0] + require.Equal(t, + map[string]string{"custom": "attribute", "ignored": "changed"}, + *updatedClient.Attributes, + "changes to ignored attributes should not trigger a client update") + + // Add a new client role that should be deleted on next reconcile + newRole := "additional-role" + _, err = mockKeycloak.CreateClientRole(ctx, keycloakAccessToken, keycloakRealm, *createdClient.ID, gocloak.Role{Name: ptr.To(newRole)}) + require.NoError(t, err) + requireClientRolesForClient(t, tkco, *createdClient.ID, append(rolesInTemplate, newRole)...) + // add a random mapping that should be deleted on next reconcile + require.NoError(t, + mockKeycloak.AddClientRolesToGroup(ctx, + keycloakAccessToken, keycloakRealm, + *createdClient.ID, *tkco.groups[1].ID, + []gocloak.Role{*tkco.clientRoles[*createdClient.ID][0]}, + )) + require.ElementsMatch(t, + tkco.clientRolesToGroupsMapping[clientGroupMappingKey{clientId: *createdClient.ID, groupId: *tkco.groups[1].ID}], + []string{"openshiftroot"}, + "sanity check to test if mapping done by test was added") + + _, err = subject.Reconcile(ctx, reconcile.Request{NamespacedName: client.ObjectKeyFromObject(cluster)}) + require.NoError(t, err) + requireClientRolesForClient(t, tkco, *createdClient.ID, rolesInTemplate...) + require.Len(t, + tkco.clientRolesToGroupsMapping[clientGroupMappingKey{clientId: *createdClient.ID, groupId: *tkco.groups[1].ID}], + 0, + "mapping not in template should have been deleted") + }) + + t.Run("UpdateSecret", func(t *testing.T) { + createdClient := tkco.clients[0] + sk := "test/keycloak/oidcClient" + + require.Equal(t, + tks.secrets[sk], + map[string]any{"secret": md5sum(*createdClient.ClientID)}, + "should have created a secret with the secret returned by keycloak") + + tks.secrets[sk]["secret"] = "invalid" + + _, err := subject.Reconcile(ctx, reconcile.Request{NamespacedName: client.ObjectKeyFromObject(cluster)}) + require.NoError(t, err) + + require.Equal(t, + tks.secrets[sk], + map[string]any{"secret": md5sum(*createdClient.ClientID)}, + "should have updated the secret with the secret returned by keycloak") + }) + + t.Run("DeleteClient", func(t *testing.T) { + require.NoError(t, c.Delete(ctx, cluster)) + + require.NoError(t, + reconcileNTimes(ctx, subject, reconcile.Request{NamespacedName: client.ObjectKeyFromObject(cluster)}, 2)) + + require.Len(t, tkco.clients, 0, "should have deleted the client") + require.Len(t, tks.secrets, 0, "should have deleted the secret") + + require.Error(t, c.Get(ctx, client.ObjectKeyFromObject(cluster), cluster), "cluster should be deleted after finalizer was removed") + }) +} + +func md5sum(s string) string { + return fmt.Sprintf("%x", md5.Sum([]byte(s))) +} + +func mockKeycloakLogin(mockKeycloak *mock.MockPartialKeycloakClient, loginRealm string) { + at, rt := keycloakAccessToken, "refresh-token" + mockKeycloak.EXPECT().LoginAdmin(gomock.Any(), gomock.Any(), gomock.Any(), loginRealm).Return(&gocloak.JWT{AccessToken: at, RefreshToken: rt}, nil).AnyTimes() + mockKeycloak.EXPECT().LogoutPublicClient(gomock.Any(), "admin-cli", loginRealm, at, rt).Return(nil).AnyTimes() +} + +func mockVaultLogin(mockVaultAuth *mock.MockVaultPartialAuthClient) { + mockVaultAuth.EXPECT().KubernetesLogin(gomock.Any(), gomock.Any(), gomock.Any()).Return(&vault.Response[map[string]interface{}]{ + Auth: &vault.ResponseAuth{ + ClientToken: vaultToken, + }, + }, nil).AnyTimes() +} + +type clientGroupMappingKey struct { + clientId, groupId string +} + +type trackedKeycloakObjects struct { + clients []*gocloak.Client + clientRoles map[string][]*gocloak.Role + groups []gocloak.Group + + clientRolesToGroupsMapping map[clientGroupMappingKey][]string +} + +func trackKeycloakObjects(mockKeycloak *mock.MockPartialKeycloakClient) *trackedKeycloakObjects { + tracked := &trackedKeycloakObjects{ + clients: make([]*gocloak.Client, 0), + clientRoles: make(map[string][]*gocloak.Role), + groups: make([]gocloak.Group, 0), + + clientRolesToGroupsMapping: make(map[clientGroupMappingKey][]string), + } + + findClient := func(clientId string) *gocloak.Client { + for _, c := range tracked.clients { + if *c.ClientID == clientId { + return c + } + } + return nil + } + + mockKeycloak.EXPECT().GetClients(gomock.Any(), keycloakAccessToken, keycloakRealm, gomock.Any()).DoAndReturn( + func(_ context.Context, _, _ string, gcp gocloak.GetClientsParams) ([]*gocloak.Client, error) { + if c := findClient(*gcp.ClientID); c != nil { + return []*gocloak.Client{c}, nil + } + return []*gocloak.Client{}, nil + }).AnyTimes() + + mockKeycloak.EXPECT().CreateClient(gomock.Any(), keycloakAccessToken, keycloakRealm, gomock.Any()).DoAndReturn( + func(_ context.Context, _, _ string, client gocloak.Client) (string, error) { + if findClient(*client.ClientID) != nil { + return "", &gocloak.APIError{Code: http.StatusConflict, Message: "Client already exists"} + } + + id := md5sum(*client.ClientID) + client.ID = &id + client.Secret = &id + tracked.clients = append(tracked.clients, &client) + return id, nil + }).AnyTimes() + + mockKeycloak.EXPECT().UpdateClient(gomock.Any(), keycloakAccessToken, keycloakRealm, gomock.Any()).DoAndReturn( + func(_ context.Context, _, _ string, client gocloak.Client) error { + oc := findClient(*client.ClientID) + if oc == nil { + return &gocloak.APIError{Code: http.StatusNotFound, Message: "Client not found"} + } + + id := md5sum(*client.ClientID) + client.ID = &id + client.Secret = &id + *oc = client + return nil + }).AnyTimes() + + mockKeycloak.EXPECT().DeleteClient(gomock.Any(), keycloakAccessToken, keycloakRealm, gomock.Any()).DoAndReturn( + func(_ context.Context, _, _ string, clientId string) error { + for i, c := range tracked.clients { + if *c.ID == clientId { + tracked.clients = append(tracked.clients[:i], tracked.clients[i+1:]...) + return nil + } + } + return &gocloak.APIError{Code: http.StatusNotFound, Message: "Client not found"} + }).AnyTimes() + + mockKeycloak.EXPECT().GetClientRoles(gomock.Any(), keycloakAccessToken, keycloakRealm, gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, _, _, id string, _ gocloak.GetRoleParams) ([]*gocloak.Role, error) { + if cr, ok := tracked.clientRoles[id]; ok { + return cr, nil + } + return []*gocloak.Role{}, nil + }).AnyTimes() + + mockKeycloak.EXPECT().CreateClientRole(gomock.Any(), keycloakAccessToken, keycloakRealm, gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, _, _, clientId string, role gocloak.Role) (string, error) { + id := md5sum(*role.Name) + role.ID = &id + if cr, ok := tracked.clientRoles[clientId]; ok { + for _, r := range cr { + if *r.Name == *role.Name { + return "", &gocloak.APIError{Code: http.StatusConflict, Message: "Role already exists"} + } + } + tracked.clientRoles[clientId] = append(tracked.clientRoles[clientId], &role) + } else { + tracked.clientRoles[clientId] = []*gocloak.Role{&role} + } + return id, nil + }).AnyTimes() + + mockKeycloak.EXPECT().DeleteClientRole(gomock.Any(), keycloakAccessToken, keycloakRealm, gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, _, _, clientId, roleName string) error { + if cr, ok := tracked.clientRoles[clientId]; ok { + for i, r := range cr { + if *r.Name == roleName { + tracked.clientRoles[clientId] = append(cr[:i], cr[i+1:]...) + return nil + } + } + } + return &gocloak.APIError{Code: http.StatusNotFound, Message: "Role not found"} + }).AnyTimes() + + mockKeycloak.EXPECT().GetGroupByPath(gomock.Any(), keycloakAccessToken, keycloakRealm, gomock.Any()).DoAndReturn( + func(_ context.Context, _, _ string, groupPath string) (*gocloak.Group, error) { + for _, g := range tracked.groups { + if *g.Path == groupPath { + return &g, nil + } + } + return nil, &gocloak.APIError{Code: http.StatusNotFound} + }).AnyTimes() + + mockKeycloak.EXPECT().GetGroupsByClientRole(gomock.Any(), keycloakAccessToken, keycloakRealm, gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, _, _, roleName, clientID string) ([]*gocloak.Group, error) { + var groups []*gocloak.Group + for k, v := range tracked.clientRolesToGroupsMapping { + if k.clientId == clientID { + for _, r := range v { + if r != roleName { + continue + } + for _, g := range tracked.groups { + g := g + if *g.ID == k.groupId { + groups = append(groups, &g) + } + } + } + } + } + return groups, nil + }).AnyTimes() + + mockKeycloak.EXPECT().AddClientRolesToGroup(gomock.Any(), keycloakAccessToken, keycloakRealm, gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, _, _, idOfClient string, groupID string, roles []gocloak.Role) error { + rs := slices.Clone(tracked.clientRolesToGroupsMapping[clientGroupMappingKey{clientId: idOfClient, groupId: groupID}]) + + for _, r := range roles { + rs = append(rs, *r.Name) + } + + slices.Sort(rs) + tracked.clientRolesToGroupsMapping[clientGroupMappingKey{clientId: idOfClient, groupId: groupID}] = slices.Compact(rs) + + return nil + }).AnyTimes() + + mockKeycloak.EXPECT().DeleteClientRoleFromGroup(gomock.Any(), keycloakAccessToken, keycloakRealm, gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, _, _, idOfClient string, groupID string, roles []gocloak.Role) error { + rs := slices.Clone(tracked.clientRolesToGroupsMapping[clientGroupMappingKey{clientId: idOfClient, groupId: groupID}]) + + for _, r := range roles { + rs = slices.DeleteFunc(rs, func(rn string) bool { + return rn == *r.Name + }) + } + + tracked.clientRolesToGroupsMapping[clientGroupMappingKey{clientId: idOfClient, groupId: groupID}] = rs + return nil + }).AnyTimes() + + return tracked +} + +type trackedSecrets struct { + secrets map[string]map[string]any +} + +func trackVaultSecretCreation(mockVaultSecrets *mock.MockVaultPartialSecretsClient) *trackedSecrets { + tr := &trackedSecrets{ + secrets: make(map[string]map[string]any), + } + + mockVaultSecrets.EXPECT().KvV2Read(gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, path string, _ ...vault.RequestOption) (*vault.Response[schema.KvV2ReadResponse], error) { + if s, ok := tr.secrets[path]; ok { + return &vault.Response[schema.KvV2ReadResponse]{ + Data: schema.KvV2ReadResponse{ + Data: s, + }, + }, nil + } + return nil, &vault.ResponseError{StatusCode: http.StatusNotFound} + }).AnyTimes() + + mockVaultSecrets.EXPECT().KvV2Write(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, path string, request schema.KvV2WriteRequest, _ ...vault.RequestOption) (*vault.Response[schema.KvV2WriteResponse], error) { + tr.secrets[path] = request.Data + return &vault.Response[schema.KvV2WriteResponse]{}, nil + }).AnyTimes() + + mockVaultSecrets.EXPECT().KvV2Delete(gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, path string, _ ...vault.RequestOption) (*vault.Response[map[string]interface{}], error) { + delete(tr.secrets, path) + return &vault.Response[map[string]any]{}, nil + }).AnyTimes() + + return tr } func fakeClient(t *testing.T, initObjs ...client.Object) client.WithWatch { @@ -53,3 +482,30 @@ func fakeClient(t *testing.T, initObjs ...client.Object) client.WithWatch { return cl } + +func vaultTokenSource() (*oauth2.Token, error) { + return &oauth2.Token{ + AccessToken: vaultAccessToken, + }, nil +} + +func reconcileNTimes(ctx context.Context, subject reconcile.Reconciler, req reconcile.Request, n int) error { + errs := make([]error, 0, n) + for i := 0; i < n; i++ { + if _, err := subject.Reconcile(ctx, req); err != nil { + errs = append(errs, fmt.Errorf("reconcile %d: %w", i, err)) + } + } + return multierr.Combine(errs...) +} + +func requireClientRolesForClient(t *testing.T, tkco *trackedKeycloakObjects, clientId string, expectedRoles ...string) { + t.Helper() + + createdClientRoles := tkco.clientRoles[clientId] + rn := make([]string, len(createdClientRoles)) + for i, r := range createdClientRoles { + rn[i] = *r.Name + } + require.ElementsMatch(t, expectedRoles, rn) +} diff --git a/controllers/interfaces.go b/controllers/interfaces.go new file mode 100644 index 0000000..e6d2454 --- /dev/null +++ b/controllers/interfaces.go @@ -0,0 +1,49 @@ +package controllers + +import ( + "context" + + "github.com/Nerzal/gocloak/v13" + "github.com/hashicorp/vault-client-go" + "github.com/hashicorp/vault-client-go/schema" + _ "go.uber.org/mock/gomock" +) + +// PartialKeycloakClient is a subset of the gocloak client methods that are used by the controller +// +//go:generate go run go.uber.org/mock/mockgen -destination=./mock/partial_keycloak_client.go -package mock . PartialKeycloakClient +type PartialKeycloakClient interface { + LoginAdmin(ctx context.Context, username, password, realm string) (*gocloak.JWT, error) + LogoutPublicClient(ctx context.Context, clientID, realm, accessToken, refreshToken string) error + + GetClients(ctx context.Context, accessToken, realm string, params gocloak.GetClientsParams) ([]*gocloak.Client, error) + CreateClient(ctx context.Context, accessToken, realm string, newClient gocloak.Client) (string, error) + UpdateClient(ctx context.Context, accessToken, realm string, updatedClient gocloak.Client) error + DeleteClient(ctx context.Context, accessToken, realm, idOfClient string) error + + GetClientRoles(ctx context.Context, accessToken, realm, idOfClient string, params gocloak.GetRoleParams) ([]*gocloak.Role, error) + AddClientRolesToGroup(ctx context.Context, token, realm, idOfClient, groupID string, roles []gocloak.Role) error + CreateClientRole(ctx context.Context, accessToken, realm, idOfClient string, role gocloak.Role) (string, error) + DeleteClientRole(ctx context.Context, token, realm, idOfClient, roleName string) error + + GetGroupByPath(ctx context.Context, token, realm, groupPath string) (*gocloak.Group, error) + GetGroupsByClientRole(ctx context.Context, token, realm, roleName, clientID string) ([]*gocloak.Group, error) + DeleteClientRoleFromGroup(ctx context.Context, token, realm, idOfClient, groupID string, roles []gocloak.Role) error +} + +// VaultPartialAuthClient is a subset of the vault auth methods that are used by the controller +// +//go:generate go run go.uber.org/mock/mockgen -destination=./mock/vault_partial_auth_client.go -package mock . VaultPartialAuthClient +type VaultPartialAuthClient interface { + KubernetesLogin(ctx context.Context, request schema.KubernetesLoginRequest, options ...vault.RequestOption) (*vault.Response[map[string]interface{}], error) +} + +// VaultPartialSecretsClient is a subset of the vault secrets methods that are used by the controller +// +// // Currently generics imports are not correctly resolved in the `vault.Response[]` +// //go:generate go run go.uber.org/mock/mockgen -destination=./mock/vault_partial_secrets_client.go -package mock . VaultPartialSecretsClient +type VaultPartialSecretsClient interface { + KvV2Read(ctx context.Context, path string, options ...vault.RequestOption) (*vault.Response[schema.KvV2ReadResponse], error) + KvV2Write(ctx context.Context, path string, request schema.KvV2WriteRequest, options ...vault.RequestOption) (*vault.Response[schema.KvV2WriteResponse], error) + KvV2Delete(ctx context.Context, path string, options ...vault.RequestOption) (*vault.Response[map[string]interface{}], error) +} diff --git a/controllers/mock/partial_keycloak_client.go b/controllers/mock/partial_keycloak_client.go new file mode 100644 index 0000000..ed2337b --- /dev/null +++ b/controllers/mock/partial_keycloak_client.go @@ -0,0 +1,229 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/projectsyn/lieutenant-keycloak-idp-controller/controllers (interfaces: PartialKeycloakClient) +// +// Generated by this command: +// +// mockgen -destination=./mock/partial_keycloak_client.go -package mock . PartialKeycloakClient +// +// Package mock is a generated GoMock package. +package mock + +import ( + context "context" + reflect "reflect" + + gocloak "github.com/Nerzal/gocloak/v13" + gomock "go.uber.org/mock/gomock" +) + +// MockPartialKeycloakClient is a mock of PartialKeycloakClient interface. +type MockPartialKeycloakClient struct { + ctrl *gomock.Controller + recorder *MockPartialKeycloakClientMockRecorder +} + +// MockPartialKeycloakClientMockRecorder is the mock recorder for MockPartialKeycloakClient. +type MockPartialKeycloakClientMockRecorder struct { + mock *MockPartialKeycloakClient +} + +// NewMockPartialKeycloakClient creates a new mock instance. +func NewMockPartialKeycloakClient(ctrl *gomock.Controller) *MockPartialKeycloakClient { + mock := &MockPartialKeycloakClient{ctrl: ctrl} + mock.recorder = &MockPartialKeycloakClientMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockPartialKeycloakClient) EXPECT() *MockPartialKeycloakClientMockRecorder { + return m.recorder +} + +// AddClientRolesToGroup mocks base method. +func (m *MockPartialKeycloakClient) AddClientRolesToGroup(arg0 context.Context, arg1, arg2, arg3, arg4 string, arg5 []gocloak.Role) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "AddClientRolesToGroup", arg0, arg1, arg2, arg3, arg4, arg5) + ret0, _ := ret[0].(error) + return ret0 +} + +// AddClientRolesToGroup indicates an expected call of AddClientRolesToGroup. +func (mr *MockPartialKeycloakClientMockRecorder) AddClientRolesToGroup(arg0, arg1, arg2, arg3, arg4, arg5 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AddClientRolesToGroup", reflect.TypeOf((*MockPartialKeycloakClient)(nil).AddClientRolesToGroup), arg0, arg1, arg2, arg3, arg4, arg5) +} + +// CreateClient mocks base method. +func (m *MockPartialKeycloakClient) CreateClient(arg0 context.Context, arg1, arg2 string, arg3 gocloak.Client) (string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CreateClient", arg0, arg1, arg2, arg3) + ret0, _ := ret[0].(string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// CreateClient indicates an expected call of CreateClient. +func (mr *MockPartialKeycloakClientMockRecorder) CreateClient(arg0, arg1, arg2, arg3 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateClient", reflect.TypeOf((*MockPartialKeycloakClient)(nil).CreateClient), arg0, arg1, arg2, arg3) +} + +// CreateClientRole mocks base method. +func (m *MockPartialKeycloakClient) CreateClientRole(arg0 context.Context, arg1, arg2, arg3 string, arg4 gocloak.Role) (string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CreateClientRole", arg0, arg1, arg2, arg3, arg4) + ret0, _ := ret[0].(string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// CreateClientRole indicates an expected call of CreateClientRole. +func (mr *MockPartialKeycloakClientMockRecorder) CreateClientRole(arg0, arg1, arg2, arg3, arg4 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateClientRole", reflect.TypeOf((*MockPartialKeycloakClient)(nil).CreateClientRole), arg0, arg1, arg2, arg3, arg4) +} + +// DeleteClient mocks base method. +func (m *MockPartialKeycloakClient) DeleteClient(arg0 context.Context, arg1, arg2, arg3 string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteClient", arg0, arg1, arg2, arg3) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteClient indicates an expected call of DeleteClient. +func (mr *MockPartialKeycloakClientMockRecorder) DeleteClient(arg0, arg1, arg2, arg3 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteClient", reflect.TypeOf((*MockPartialKeycloakClient)(nil).DeleteClient), arg0, arg1, arg2, arg3) +} + +// DeleteClientRole mocks base method. +func (m *MockPartialKeycloakClient) DeleteClientRole(arg0 context.Context, arg1, arg2, arg3, arg4 string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteClientRole", arg0, arg1, arg2, arg3, arg4) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteClientRole indicates an expected call of DeleteClientRole. +func (mr *MockPartialKeycloakClientMockRecorder) DeleteClientRole(arg0, arg1, arg2, arg3, arg4 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteClientRole", reflect.TypeOf((*MockPartialKeycloakClient)(nil).DeleteClientRole), arg0, arg1, arg2, arg3, arg4) +} + +// DeleteClientRoleFromGroup mocks base method. +func (m *MockPartialKeycloakClient) DeleteClientRoleFromGroup(arg0 context.Context, arg1, arg2, arg3, arg4 string, arg5 []gocloak.Role) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteClientRoleFromGroup", arg0, arg1, arg2, arg3, arg4, arg5) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteClientRoleFromGroup indicates an expected call of DeleteClientRoleFromGroup. +func (mr *MockPartialKeycloakClientMockRecorder) DeleteClientRoleFromGroup(arg0, arg1, arg2, arg3, arg4, arg5 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteClientRoleFromGroup", reflect.TypeOf((*MockPartialKeycloakClient)(nil).DeleteClientRoleFromGroup), arg0, arg1, arg2, arg3, arg4, arg5) +} + +// GetClientRoles mocks base method. +func (m *MockPartialKeycloakClient) GetClientRoles(arg0 context.Context, arg1, arg2, arg3 string, arg4 gocloak.GetRoleParams) ([]*gocloak.Role, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetClientRoles", arg0, arg1, arg2, arg3, arg4) + ret0, _ := ret[0].([]*gocloak.Role) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetClientRoles indicates an expected call of GetClientRoles. +func (mr *MockPartialKeycloakClientMockRecorder) GetClientRoles(arg0, arg1, arg2, arg3, arg4 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetClientRoles", reflect.TypeOf((*MockPartialKeycloakClient)(nil).GetClientRoles), arg0, arg1, arg2, arg3, arg4) +} + +// GetClients mocks base method. +func (m *MockPartialKeycloakClient) GetClients(arg0 context.Context, arg1, arg2 string, arg3 gocloak.GetClientsParams) ([]*gocloak.Client, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetClients", arg0, arg1, arg2, arg3) + ret0, _ := ret[0].([]*gocloak.Client) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetClients indicates an expected call of GetClients. +func (mr *MockPartialKeycloakClientMockRecorder) GetClients(arg0, arg1, arg2, arg3 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetClients", reflect.TypeOf((*MockPartialKeycloakClient)(nil).GetClients), arg0, arg1, arg2, arg3) +} + +// GetGroupByPath mocks base method. +func (m *MockPartialKeycloakClient) GetGroupByPath(arg0 context.Context, arg1, arg2, arg3 string) (*gocloak.Group, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetGroupByPath", arg0, arg1, arg2, arg3) + ret0, _ := ret[0].(*gocloak.Group) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetGroupByPath indicates an expected call of GetGroupByPath. +func (mr *MockPartialKeycloakClientMockRecorder) GetGroupByPath(arg0, arg1, arg2, arg3 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetGroupByPath", reflect.TypeOf((*MockPartialKeycloakClient)(nil).GetGroupByPath), arg0, arg1, arg2, arg3) +} + +// GetGroupsByClientRole mocks base method. +func (m *MockPartialKeycloakClient) GetGroupsByClientRole(arg0 context.Context, arg1, arg2, arg3, arg4 string) ([]*gocloak.Group, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetGroupsByClientRole", arg0, arg1, arg2, arg3, arg4) + ret0, _ := ret[0].([]*gocloak.Group) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetGroupsByClientRole indicates an expected call of GetGroupsByClientRole. +func (mr *MockPartialKeycloakClientMockRecorder) GetGroupsByClientRole(arg0, arg1, arg2, arg3, arg4 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetGroupsByClientRole", reflect.TypeOf((*MockPartialKeycloakClient)(nil).GetGroupsByClientRole), arg0, arg1, arg2, arg3, arg4) +} + +// LoginAdmin mocks base method. +func (m *MockPartialKeycloakClient) LoginAdmin(arg0 context.Context, arg1, arg2, arg3 string) (*gocloak.JWT, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "LoginAdmin", arg0, arg1, arg2, arg3) + ret0, _ := ret[0].(*gocloak.JWT) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// LoginAdmin indicates an expected call of LoginAdmin. +func (mr *MockPartialKeycloakClientMockRecorder) LoginAdmin(arg0, arg1, arg2, arg3 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "LoginAdmin", reflect.TypeOf((*MockPartialKeycloakClient)(nil).LoginAdmin), arg0, arg1, arg2, arg3) +} + +// LogoutPublicClient mocks base method. +func (m *MockPartialKeycloakClient) LogoutPublicClient(arg0 context.Context, arg1, arg2, arg3, arg4 string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "LogoutPublicClient", arg0, arg1, arg2, arg3, arg4) + ret0, _ := ret[0].(error) + return ret0 +} + +// LogoutPublicClient indicates an expected call of LogoutPublicClient. +func (mr *MockPartialKeycloakClientMockRecorder) LogoutPublicClient(arg0, arg1, arg2, arg3, arg4 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "LogoutPublicClient", reflect.TypeOf((*MockPartialKeycloakClient)(nil).LogoutPublicClient), arg0, arg1, arg2, arg3, arg4) +} + +// UpdateClient mocks base method. +func (m *MockPartialKeycloakClient) UpdateClient(arg0 context.Context, arg1, arg2 string, arg3 gocloak.Client) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateClient", arg0, arg1, arg2, arg3) + ret0, _ := ret[0].(error) + return ret0 +} + +// UpdateClient indicates an expected call of UpdateClient. +func (mr *MockPartialKeycloakClientMockRecorder) UpdateClient(arg0, arg1, arg2, arg3 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateClient", reflect.TypeOf((*MockPartialKeycloakClient)(nil).UpdateClient), arg0, arg1, arg2, arg3) +} diff --git a/controllers/mock/vault_partial_auth_client.go b/controllers/mock/vault_partial_auth_client.go new file mode 100644 index 0000000..5b51b96 --- /dev/null +++ b/controllers/mock/vault_partial_auth_client.go @@ -0,0 +1,61 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/projectsyn/lieutenant-keycloak-idp-controller/controllers (interfaces: VaultPartialAuthClient) +// +// Generated by this command: +// +// mockgen -destination=./mock/vault_partial_auth_client.go -package mock . VaultPartialAuthClient +// +// Package mock is a generated GoMock package. +package mock + +import ( + context "context" + reflect "reflect" + + vault "github.com/hashicorp/vault-client-go" + schema "github.com/hashicorp/vault-client-go/schema" + gomock "go.uber.org/mock/gomock" +) + +// MockVaultPartialAuthClient is a mock of VaultPartialAuthClient interface. +type MockVaultPartialAuthClient struct { + ctrl *gomock.Controller + recorder *MockVaultPartialAuthClientMockRecorder +} + +// MockVaultPartialAuthClientMockRecorder is the mock recorder for MockVaultPartialAuthClient. +type MockVaultPartialAuthClientMockRecorder struct { + mock *MockVaultPartialAuthClient +} + +// NewMockVaultPartialAuthClient creates a new mock instance. +func NewMockVaultPartialAuthClient(ctrl *gomock.Controller) *MockVaultPartialAuthClient { + mock := &MockVaultPartialAuthClient{ctrl: ctrl} + mock.recorder = &MockVaultPartialAuthClientMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockVaultPartialAuthClient) EXPECT() *MockVaultPartialAuthClientMockRecorder { + return m.recorder +} + +// KubernetesLogin mocks base method. +func (m *MockVaultPartialAuthClient) KubernetesLogin(arg0 context.Context, arg1 schema.KubernetesLoginRequest, arg2 ...vault.RequestOption) (*vault.Response[map[string]interface{}], error) { + m.ctrl.T.Helper() + varargs := []any{arg0, arg1} + for _, a := range arg2 { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "KubernetesLogin", varargs...) + ret0, _ := ret[0].(*vault.Response[map[string]interface{}]) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// KubernetesLogin indicates an expected call of KubernetesLogin. +func (mr *MockVaultPartialAuthClientMockRecorder) KubernetesLogin(arg0, arg1 any, arg2 ...any) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]any{arg0, arg1}, arg2...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "KubernetesLogin", reflect.TypeOf((*MockVaultPartialAuthClient)(nil).KubernetesLogin), varargs...) +} diff --git a/controllers/mock/vault_partial_secrets_client.go b/controllers/mock/vault_partial_secrets_client.go new file mode 100644 index 0000000..512bfac --- /dev/null +++ b/controllers/mock/vault_partial_secrets_client.go @@ -0,0 +1,100 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/projectsyn/lieutenant-keycloak-idp-controller/controllers (interfaces: VaultPartialSecretsClient) +// +// Generated by this command: +// +// mockgen -destination=./mock/vault_partial_secrets_client.go -package mock . VaultPartialSecretsClient +// +// Package mock is a generated GoMock package. +package mock + +import ( + context "context" + vault "github.com/hashicorp/vault-client-go" + schema "github.com/hashicorp/vault-client-go/schema" + gomock "go.uber.org/mock/gomock" + reflect "reflect" +) + +// MockVaultPartialSecretsClient is a mock of VaultPartialSecretsClient interface. +type MockVaultPartialSecretsClient struct { + ctrl *gomock.Controller + recorder *MockVaultPartialSecretsClientMockRecorder +} + +// MockVaultPartialSecretsClientMockRecorder is the mock recorder for MockVaultPartialSecretsClient. +type MockVaultPartialSecretsClientMockRecorder struct { + mock *MockVaultPartialSecretsClient +} + +// NewMockVaultPartialSecretsClient creates a new mock instance. +func NewMockVaultPartialSecretsClient(ctrl *gomock.Controller) *MockVaultPartialSecretsClient { + mock := &MockVaultPartialSecretsClient{ctrl: ctrl} + mock.recorder = &MockVaultPartialSecretsClientMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockVaultPartialSecretsClient) EXPECT() *MockVaultPartialSecretsClientMockRecorder { + return m.recorder +} + +// KvV2Delete mocks base method. +func (m *MockVaultPartialSecretsClient) KvV2Delete(arg0 context.Context, arg1 string, arg2 ...vault.RequestOption) (*vault.Response[map[string]interface{}], error) { + m.ctrl.T.Helper() + varargs := []any{arg0, arg1} + for _, a := range arg2 { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "KvV2Delete", varargs...) + ret0, _ := ret[0].(*vault.Response[map[string]interface{}]) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// KvV2Delete indicates an expected call of KvV2Delete. +func (mr *MockVaultPartialSecretsClientMockRecorder) KvV2Delete(arg0, arg1 any, arg2 ...any) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]any{arg0, arg1}, arg2...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "KvV2Delete", reflect.TypeOf((*MockVaultPartialSecretsClient)(nil).KvV2Delete), varargs...) +} + +// KvV2Read mocks base method. +func (m *MockVaultPartialSecretsClient) KvV2Read(arg0 context.Context, arg1 string, arg2 ...vault.RequestOption) (*vault.Response[schema.KvV2ReadResponse], error) { + m.ctrl.T.Helper() + varargs := []any{arg0, arg1} + for _, a := range arg2 { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "KvV2Read", varargs...) + ret0, _ := ret[0].(*vault.Response[schema.KvV2ReadResponse]) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// KvV2Read indicates an expected call of KvV2Read. +func (mr *MockVaultPartialSecretsClientMockRecorder) KvV2Read(arg0, arg1 any, arg2 ...any) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]any{arg0, arg1}, arg2...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "KvV2Read", reflect.TypeOf((*MockVaultPartialSecretsClient)(nil).KvV2Read), varargs...) +} + +// KvV2Write mocks base method. +func (m *MockVaultPartialSecretsClient) KvV2Write(arg0 context.Context, arg1 string, arg2 schema.KvV2WriteRequest, arg3 ...vault.RequestOption) (*vault.Response[schema.KvV2WriteResponse], error) { + m.ctrl.T.Helper() + varargs := []any{arg0, arg1, arg2} + for _, a := range arg3 { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "KvV2Write", varargs...) + ret0, _ := ret[0].(*vault.Response[schema.KvV2WriteResponse]) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// KvV2Write indicates an expected call of KvV2Write. +func (mr *MockVaultPartialSecretsClientMockRecorder) KvV2Write(arg0, arg1, arg2 any, arg3 ...any) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]any{arg0, arg1, arg2}, arg3...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "KvV2Write", reflect.TypeOf((*MockVaultPartialSecretsClient)(nil).KvV2Write), varargs...) +} diff --git a/controllers/testtemplates/client-roles.jsonnet b/controllers/testtemplates/client-roles.jsonnet new file mode 100644 index 0000000..8d543b9 --- /dev/null +++ b/controllers/testtemplates/client-roles.jsonnet @@ -0,0 +1,15 @@ +local context = std.extVar('context'); + +[ + { + role: 'openshiftroot', + group: '/LDAP/VSHN openshiftroot', + }, + { + role: 'openshiftrootswissonly', + }, + { + role: 'restricted-access', + group: '/LDAP_Customers/Service %s' % context.cluster.metadata.name, + }, +] diff --git a/controllers/testtemplates/client.jsonnet b/controllers/testtemplates/client.jsonnet new file mode 100644 index 0000000..38c9c1e --- /dev/null +++ b/controllers/testtemplates/client.jsonnet @@ -0,0 +1,12 @@ +local context = std.extVar('context'); + +{ + clientId: 'cluster_%s' % context.cluster.metadata.name, + name: '%s (%s)' % [ context.cluster.spec.displayName, context.cluster.metadata.name ], + rootUrl: 'https://oauth-openshift.apps.%s.dev' % context.cluster.metadata.name, + redirectUris: [ '/oauth2/callback' ], + attributes: { + custom: 'attribute', + ignored: 'attribute', + }, +} diff --git a/controllers/testtemplates/embed.go b/controllers/testtemplates/embed.go new file mode 100644 index 0000000..4a7e976 --- /dev/null +++ b/controllers/testtemplates/embed.go @@ -0,0 +1,11 @@ +package testtemplates + +import ( + _ "embed" +) + +//go:embed client.jsonnet +var Client string + +//go:embed client-roles.jsonnet +var ClientRoles string diff --git a/docs/keycloak-config/user-details.png b/docs/keycloak-config/user-details.png new file mode 100644 index 0000000..147eb27 Binary files /dev/null and b/docs/keycloak-config/user-details.png differ diff --git a/docs/keycloak-config/user-pw.png b/docs/keycloak-config/user-pw.png new file mode 100644 index 0000000..6f153d3 Binary files /dev/null and b/docs/keycloak-config/user-pw.png differ diff --git a/docs/keycloak-config/user-roles.png b/docs/keycloak-config/user-roles.png new file mode 100644 index 0000000..c59b3b7 Binary files /dev/null and b/docs/keycloak-config/user-roles.png differ diff --git a/go.mod b/go.mod index ce4f9e4..4e9cbc7 100644 --- a/go.mod +++ b/go.mod @@ -5,11 +5,20 @@ go 1.21 toolchain go1.21.4 require ( + github.com/Nerzal/gocloak/v13 v13.8.0 github.com/go-logr/logr v1.3.0 + github.com/google/go-jsonnet v0.20.0 + github.com/hashicorp/vault-client-go v0.4.2 github.com/projectsyn/lieutenant-operator v1.5.0 github.com/stretchr/testify v1.8.4 + github.com/wI2L/jsondiff v0.5.0 + go.uber.org/mock v0.3.0 + go.uber.org/multierr v1.11.0 + golang.org/x/exp v0.0.0-20231127185646-65229373498e + golang.org/x/oauth2 v0.15.0 k8s.io/apimachinery v0.28.4 k8s.io/client-go v0.28.4 + k8s.io/utils v0.0.0-20231127182322-b307cd553661 sigs.k8s.io/controller-runtime v0.16.3 sigs.k8s.io/controller-tools v0.13.0 sigs.k8s.io/kustomize/kustomize/v5 v5.2.1 @@ -22,15 +31,17 @@ require ( github.com/emicklei/go-restful/v3 v3.11.0 // indirect github.com/evanphx/json-patch v5.7.0+incompatible // indirect github.com/evanphx/json-patch/v5 v5.7.0 // indirect - github.com/fatih/color v1.15.0 // indirect + github.com/fatih/color v1.16.0 // indirect github.com/fsnotify/fsnotify v1.7.0 // indirect github.com/go-errors/errors v1.5.1 // indirect - github.com/go-logr/zapr v1.2.4 // indirect + github.com/go-logr/zapr v1.3.0 // indirect github.com/go-openapi/jsonpointer v0.20.0 // indirect github.com/go-openapi/jsonreference v0.20.2 // indirect github.com/go-openapi/swag v0.22.4 // indirect + github.com/go-resty/resty/v2 v2.10.0 // indirect github.com/gobuffalo/flect v1.0.2 // indirect github.com/gogo/protobuf v1.3.2 // indirect + github.com/golang-jwt/jwt/v4 v4.5.0 // indirect github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect github.com/golang/protobuf v1.5.3 // indirect github.com/google/gnostic-models v0.6.8 // indirect @@ -38,6 +49,10 @@ require ( github.com/google/gofuzz v1.2.0 // indirect github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect github.com/google/uuid v1.4.0 // indirect + github.com/hashicorp/go-cleanhttp v0.5.2 // indirect + github.com/hashicorp/go-retryablehttp v0.7.5 // indirect + github.com/hashicorp/go-rootcerts v1.0.2 // indirect + github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 // indirect github.com/imdario/mergo v0.3.16 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/josharian/intern v1.0.0 // indirect @@ -46,31 +61,36 @@ require ( github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 // indirect + github.com/mitchellh/go-homedir v1.1.0 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/opentracing/opentracing-go v1.2.0 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/prometheus/client_golang v1.17.0 // indirect github.com/prometheus/client_model v0.5.0 // indirect github.com/prometheus/common v0.45.0 // indirect github.com/prometheus/procfs v0.12.0 // indirect - github.com/spf13/cobra v1.7.0 // indirect + github.com/ryanuber/go-glob v1.0.0 // indirect + github.com/segmentio/ksuid v1.0.4 // indirect + github.com/spf13/cobra v1.8.0 // indirect github.com/spf13/pflag v1.0.5 // indirect + github.com/tidwall/gjson v1.17.0 // indirect + github.com/tidwall/match v1.1.1 // indirect + github.com/tidwall/pretty v1.2.1 // indirect + github.com/tidwall/sjson v1.2.5 // indirect github.com/xlab/treeprint v1.2.0 // indirect - go.starlark.net v0.0.0-20231016134836-22325403fcb3 // indirect - go.uber.org/multierr v1.11.0 // indirect + go.starlark.net v0.0.0-20231121155337-90ade8b19d09 // indirect go.uber.org/zap v1.26.0 // indirect - golang.org/x/exp v0.0.0-20231006140011-7918f672742d // indirect - golang.org/x/mod v0.13.0 // indirect - golang.org/x/net v0.17.0 // indirect - golang.org/x/oauth2 v0.13.0 // indirect - golang.org/x/sys v0.13.0 // indirect - golang.org/x/term v0.13.0 // indirect - golang.org/x/text v0.13.0 // indirect - golang.org/x/time v0.3.0 // indirect - golang.org/x/tools v0.14.0 // indirect + golang.org/x/mod v0.14.0 // indirect + golang.org/x/net v0.19.0 // indirect + golang.org/x/sys v0.15.0 // indirect + golang.org/x/term v0.15.0 // indirect + golang.org/x/text v0.14.0 // indirect + golang.org/x/time v0.5.0 // indirect + golang.org/x/tools v0.16.0 // indirect gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect google.golang.org/appengine v1.6.8 // indirect google.golang.org/protobuf v1.31.0 // indirect @@ -79,15 +99,14 @@ require ( gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect k8s.io/api v0.28.4 // indirect - k8s.io/apiextensions-apiserver v0.28.3 // indirect - k8s.io/component-base v0.28.3 // indirect - k8s.io/klog/v2 v2.100.1 // indirect - k8s.io/kube-openapi v0.0.0-20231010175941-2dd684a91f00 // indirect - k8s.io/utils v0.0.0-20230726121419-3b25d923346b // indirect + k8s.io/apiextensions-apiserver v0.28.4 // indirect + k8s.io/component-base v0.28.4 // indirect + k8s.io/klog/v2 v2.110.1 // indirect + k8s.io/kube-openapi v0.0.0-20231129212854-f0671cc7e66a // indirect sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd // indirect sigs.k8s.io/kustomize/api v0.15.0 // indirect sigs.k8s.io/kustomize/cmd/config v0.12.0 // indirect sigs.k8s.io/kustomize/kyaml v0.15.0 // indirect - sigs.k8s.io/structured-merge-diff/v4 v4.4.0 // indirect + sigs.k8s.io/structured-merge-diff/v4 v4.4.1 // indirect sigs.k8s.io/yaml v1.4.0 // indirect ) diff --git a/go.sum b/go.sum index 30cdda7..e9b9a91 100644 --- a/go.sum +++ b/go.sum @@ -1,9 +1,10 @@ -github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= +github.com/Nerzal/gocloak/v13 v13.8.0 h1:7s9cK8X3vy8OIic+pG4POE9vGy02tSHkMhvWXv0P2m8= +github.com/Nerzal/gocloak/v13 v13.8.0/go.mod h1:rRBtEdh5N0+JlZZEsrfZcB2sRMZWbgSxI2EIv9jpJp4= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= @@ -14,18 +15,16 @@ github.com/evanphx/json-patch v5.7.0+incompatible h1:vgGkfT/9f8zE6tvSCe74nfpAVDQ github.com/evanphx/json-patch v5.7.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= github.com/evanphx/json-patch/v5 v5.7.0 h1:nJqP7uwL84RJInrohHfW0Fx3awjbm8qZeFv0nW9SYGc= github.com/evanphx/json-patch/v5 v5.7.0/go.mod h1:VNkHZ/282BpEyt/tObQO8s5CMPmYYq14uClGH4abBuQ= -github.com/fatih/color v1.15.0 h1:kOqh6YHBtK8aywxGerMG2Eq3H6Qgoqeo13Bk2Mv/nBs= -github.com/fatih/color v1.15.0/go.mod h1:0h5ZqXfHYED7Bhv2ZJamyIOUej9KtShiJESRwBDUSsw= +github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= +github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= github.com/go-errors/errors v1.5.1 h1:ZwEMSLRCapFLflTpT7NKaAc7ukJ8ZPEjzlxt8rPN8bk= github.com/go-errors/errors v1.5.1/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og= -github.com/go-logr/logr v1.2.0/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= -github.com/go-logr/logr v1.2.4/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.3.0 h1:2y3SDp0ZXuc6/cjLSZ+Q3ir+QB9T/iG5yYRXqsagWSY= github.com/go-logr/logr v1.3.0/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= -github.com/go-logr/zapr v1.2.4 h1:QHVo+6stLbfJmYGkQ7uGHUCu5hnAFAj6mDe6Ea0SeOo= -github.com/go-logr/zapr v1.2.4/go.mod h1:FyHWQIzQORZ0QVE1BtVHv3cKtNLuXsbNLtpuhNapBOA= +github.com/go-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ= +github.com/go-logr/zapr v1.3.0/go.mod h1:YKepepNBd1u/oyhd/yQmtjVXmm9uML4IXUgMOwR8/Gg= github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs= github.com/go-openapi/jsonpointer v0.20.0 h1:ESKJdU9ASRfaPNOPRx12IUyA1vn3R9GiE3KYD14BXdQ= github.com/go-openapi/jsonpointer v0.20.0/go.mod h1:6PGzBjjIIumbLYysB73Klnms1mwnU4G3YHOECG3CedA= @@ -34,12 +33,16 @@ github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= github.com/go-openapi/swag v0.22.4 h1:QLMzNJnMGPRNDCbySlcj1x01tzU8/9LTTL9hZZZogBU= github.com/go-openapi/swag v0.22.4/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= +github.com/go-resty/resty/v2 v2.10.0 h1:Qla4W/+TMmv0fOeeRqzEpXPLfTUnR5HZ1+lGs+CkiCo= +github.com/go-resty/resty/v2 v2.10.0/go.mod h1:iiP/OpA0CkcL3IGt1O0+/SIItFUbkkyw5BGXiVdTu+A= github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI= github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572/go.mod h1:9Pwr4B2jHnOSGXyyzV8ROjYa2ojvAY6HCGYYfMoC3Ls= github.com/gobuffalo/flect v1.0.2 h1:eqjPGSo2WmjgY2XlpGwo2NXgL3RucAKo4k4qQMNA5sA= github.com/gobuffalo/flect v1.0.2/go.mod h1:A5msMlrHtLqh9umBSnvabjsMrCcCpAyzglnDvkbYKHs= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang-jwt/jwt/v4 v4.5.0 h1:7cYmW1XlMY7h7ii7UhUyChSgS5wUJEnm9uZVTGqOWzg= +github.com/golang-jwt/jwt/v4 v4.5.0/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= @@ -52,6 +55,8 @@ github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/ github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-jsonnet v0.20.0 h1:WG4TTSARuV7bSm4PMB4ohjxe33IHT5WVTrJSU33uT4g= +github.com/google/go-jsonnet v0.20.0/go.mod h1:VbgWF9JX7ztlv770x/TolZNGGFfiHEVx9G6ca2eUmeA= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= @@ -61,6 +66,19 @@ github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaU github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= github.com/google/uuid v1.4.0 h1:MtMxsa51/r9yyhkyLsVeVt0B+BGQZzpQiTQ4eHZ8bc4= github.com/google/uuid v1.4.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= +github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= +github.com/hashicorp/go-hclog v0.9.2/go.mod h1:5CU+agLiy3J7N7QjHK5d05KxGsuXiQLrjA0H7acj2lQ= +github.com/hashicorp/go-hclog v0.16.2 h1:K4ev2ib4LdQETX5cSZBG0DVLk1jwGqSPXBjdah3veNs= +github.com/hashicorp/go-hclog v0.16.2/go.mod h1:whpDNt7SSdeAju8AWKIWsul05p54N/39EeqMAyrmvFQ= +github.com/hashicorp/go-retryablehttp v0.7.5 h1:bJj+Pj19UZMIweq/iie+1u5YCdGrnxCT9yvm0e+Nd5M= +github.com/hashicorp/go-retryablehttp v0.7.5/go.mod h1:Jy/gPYAdjqffZ/yFGCFV2doI5wjtH1ewM9u8iYVjtX8= +github.com/hashicorp/go-rootcerts v1.0.2 h1:jzhAVGtqPKbwpyCPELlgNWhE1znq+qwJtW5Oi2viEzc= +github.com/hashicorp/go-rootcerts v1.0.2/go.mod h1:pqUvnprVnM5bf7AOirdbb01K4ccR319Vf4pU3K5EGc8= +github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 h1:kes8mmyCpxJsI7FTwtzRqEy9CdjCtrXrXGuOpxEA7Ts= +github.com/hashicorp/go-secure-stdlib/strutil v0.1.2/go.mod h1:Gou2R9+il93BqX25LAKCLuM+y9U2T4hlwvT1yprcna4= +github.com/hashicorp/vault-client-go v0.4.2 h1:XeUXb5jnDuCUhC8HRpkdGPLh1XtzXmiOnF0mXEbARxI= +github.com/hashicorp/vault-client-go v0.4.2/go.mod h1:4tDw7Uhq5XOxS1fO+oMtotHL7j4sB9cp0T7U6m4FzDY= github.com/imdario/mergo v0.3.16 h1:wwQJbIsHYGMUyLSPrEq1CT16AhnhNJQ51+4fdHUnCl4= github.com/imdario/mergo v0.3.16/go.mod h1:WBLT9ZmE3lPoWsEzCh9LPo3TiwVN+ZKEjmz+hD27ysY= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= @@ -71,7 +89,6 @@ github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnr github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= -github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= @@ -88,6 +105,8 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 h1:jWpvCLoY8Z/e3VKvlsiIGKtc+UG6U5vzxaoagmhXfyg= github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0/go.mod h1:QUyp042oQthUoa9bqDv0ER0wrtXnBruoNd7aNjkbP+k= +github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= +github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -105,7 +124,8 @@ github.com/onsi/ginkgo/v2 v2.11.0 h1:WgqUCUt/lT6yXoQ8Wef0fsNn5cAuMK7+KT9UFRz2tcU github.com/onsi/ginkgo/v2 v2.11.0/go.mod h1:ZhrRA5XmEE3x3rhlzamx/JJvujdZoJ2uvgI7kR0iZvM= github.com/onsi/gomega v1.27.10 h1:naR28SdDFlqrG6kScpT8VWpu1xWY5nJRCF3XaYyBjhI= github.com/onsi/gomega v1.27.10/go.mod h1:RsS8tutOdbdgzbPtzzATp12yT7kM5I5aElG3evPbQ0M= -github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/opentracing/opentracing-go v1.2.0 h1:uEJPy/1a5RIPAJ0Ov+OIO8OxWu77jEv+1B0VhjKrZUs= +github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= @@ -123,16 +143,21 @@ github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3c github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkBk= +github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc= +github.com/segmentio/ksuid v1.0.4 h1:sBo2BdShXjmcugAMwjugoGUdUV0pcxY5mW4xKRn3v4c= +github.com/segmentio/ksuid v1.0.4/go.mod h1:/XUiZBD3kVx5SmUOl55voK5yeAbBNNIed+2O73XgrPE= github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0= github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= -github.com/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I= -github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0= +github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0= +github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= @@ -140,90 +165,109 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/gjson v1.17.0 h1:/Jocvlh98kcTfpN2+JzGQWQcqrPQwDrVEMApx/M5ZwM= +github.com/tidwall/gjson v1.17.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= +github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= +github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= +github.com/wI2L/jsondiff v0.5.0 h1:RRMTi/mH+R2aXcPe1VYyvGINJqQfC3R+KSEakuU1Ikw= +github.com/wI2L/jsondiff v0.5.0/go.mod h1:qqG6hnK0Lsrz2BpIVCxWiK9ItsBCpIZQiv0izJjOZ9s= github.com/xlab/treeprint v1.2.0 h1:HzHnuAF1plUN2zGlAFHbSQP2qJ0ZAD3XF5XD7OesXRQ= github.com/xlab/treeprint v1.2.0/go.mod h1:gj5Gd3gPdKtR1ikdDK6fnFLdmIS0X30kTTuNd/WEJu0= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= -go.starlark.net v0.0.0-20231016134836-22325403fcb3 h1:CKbpFNZNfaNyEWd6C+F1vLZ0WJjukoU45zDErBmRKPs= -go.starlark.net v0.0.0-20231016134836-22325403fcb3/go.mod h1:LcLNIzVOMp4oV+uusnpk+VU+SzXaJakUuBjoCSWH5dM= -go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= -go.uber.org/goleak v1.1.11/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ= +go.starlark.net v0.0.0-20231121155337-90ade8b19d09 h1:hzy3LFnSN8kuQK8h9tHl4ndF6UruMj47OqwqsS+/Ai4= +go.starlark.net v0.0.0-20231121155337-90ade8b19d09/go.mod h1:LcLNIzVOMp4oV+uusnpk+VU+SzXaJakUuBjoCSWH5dM= go.uber.org/goleak v1.2.1 h1:NBol2c7O1ZokfZ0LEU9K6Whx/KnwvepVetCUhtKja4A= go.uber.org/goleak v1.2.1/go.mod h1:qlT2yGI9QafXHhZZLxlSuNsMw3FFLxBr+tBRlmO1xH4= -go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= +go.uber.org/mock v0.3.0 h1:3mUxI1No2/60yUYax92Pt8eNOEecx2D3lcXZh2NEZJo= +go.uber.org/mock v0.3.0/go.mod h1:a6FSlNadKUHUa9IP5Vyt1zh4fC7uAwxMutEAscFbkZc= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= -go.uber.org/zap v1.24.0/go.mod h1:2kMP+WWQ8aoFoedH3T2sq6iJ2yDWpHbP0f6MQbS9Gkg= go.uber.org/zap v1.26.0 h1:sI7k6L95XOKS281NhVKOFCUNIvv9e0w4BF8N3u+tCRo= go.uber.org/zap v1.26.0/go.mod h1:dtElttAiwGvoJ/vj4IwHBS/gXsEu/pZ50mUIRWuG0so= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= -golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= -golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= +golang.org/x/crypto v0.16.0 h1:mMMrFzRSCF0GvB7Ne27XVtVAaXLrPmgPC7/v0tkwHaY= +golang.org/x/crypto v0.16.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= +golang.org/x/exp v0.0.0-20231127185646-65229373498e h1:Gvh4YaCaXNs6dKTlfgismwWZKyjVZXwOPfIyUaqU3No= +golang.org/x/exp v0.0.0-20231127185646-65229373498e/go.mod h1:iRJReGqOEeBhDZGkGbynYwcHlctCvnjTYIamk7uXpHI= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= -golang.org/x/mod v0.13.0 h1:I/DsJXRlw/8l/0c24sM9yb0T4z9liZTduXvdAWYiysY= -golang.org/x/mod v0.13.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= -golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.14.0 h1:dGoOF9QVLYng8IHTm7BAyWqCqSheQ5pYWGhzW00YJr0= +golang.org/x/mod v0.14.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= -golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= -golang.org/x/oauth2 v0.13.0 h1:jDDenyj+WgFtmV3zYVoi8aE2BwtXFLWOA67ZfNWftiY= -golang.org/x/oauth2 v0.13.0/go.mod h1:/JMhi4ZRXAf4HG9LiNmxvk+45+96RUlVThiH8FzNBn0= +golang.org/x/net v0.19.0 h1:zTwKpTd2XuCqf8huc7Fo2iSy+4RHPd10s4KzeTnVr1c= +golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U= +golang.org/x/oauth2 v0.15.0 h1:s8pnnxNVzjWyrvYdFUQq5llS1PX2zhPXmccZv99h7uQ= +golang.org/x/oauth2 v0.15.0/go.mod h1:q48ptWNTY5XWf+JNten23lcvHpLJ0ZSxF5ttTHKVCAM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.4.0 h1:zxkM55ReGkDlKSM+Fu41A+zmbZuaPVbGMzvvdUPznYQ= -golang.org/x/sync v0.4.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.5.0 h1:60k92dhOjHxJkrqnwsfl8KuaHbn/5dl0lUPUklKo3qE= +golang.org/x/sync v0.5.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc= +golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= -golang.org/x/term v0.13.0 h1:bb+I9cTfFazGW51MZqBVmZy7+JEJMouUHTUSKVQLBek= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U= +golang.org/x/term v0.15.0 h1:y/Oo/a/q3IXu26lQgl04j/gjuBDOBlx7X6Om1j2CPW4= +golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= -golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= -golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= +golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= -golang.org/x/tools v0.14.0 h1:jvNa2pY0M4r62jkRQ6RwEZZyPcymeL9XZMLBbV7U2nc= -golang.org/x/tools v0.14.0/go.mod h1:uYBEerGOWcJyEORxN+Ek8+TT266gXkNlHdJBwexUsBg= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.16.0 h1:GO788SKMRunPIBCXiQyo2AaexLstOrVhuAL5YwsckQM= +golang.org/x/tools v0.16.0/go.mod h1:kYVVN6I1mBNoB1OX+noeBjbRk4IUEPa7JJ+TJMEooJ0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= @@ -237,7 +281,6 @@ google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQ google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8= google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/evanphx/json-patch.v5 v5.7.0 h1:dGKGylPlZ/jus2g1YqhhyzfH0gPy2R8/MYUpW/OslTY= @@ -254,20 +297,20 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= k8s.io/api v0.28.4 h1:8ZBrLjwosLl/NYgv1P7EQLqoO8MGQApnbgH8tu3BMzY= k8s.io/api v0.28.4/go.mod h1:axWTGrY88s/5YE+JSt4uUi6NMM+gur1en2REMR7IRj0= -k8s.io/apiextensions-apiserver v0.28.3 h1:Od7DEnhXHnHPZG+W9I97/fSQkVpVPQx2diy+2EtmY08= -k8s.io/apiextensions-apiserver v0.28.3/go.mod h1:NE1XJZ4On0hS11aWWJUTNkmVB03j9LM7gJSisbRt8Lc= +k8s.io/apiextensions-apiserver v0.28.4 h1:AZpKY/7wQ8n+ZYDtNHbAJBb+N4AXXJvyZx6ww6yAJvU= +k8s.io/apiextensions-apiserver v0.28.4/go.mod h1:pgQIZ1U8eJSMQcENew/0ShUTlePcSGFq6dxSxf2mwPM= k8s.io/apimachinery v0.28.4 h1:zOSJe1mc+GxuMnFzD4Z/U1wst50X28ZNsn5bhgIIao8= k8s.io/apimachinery v0.28.4/go.mod h1:wI37ncBvfAoswfq626yPTe6Bz1c22L7uaJ8dho83mgg= k8s.io/client-go v0.28.4 h1:Np5ocjlZcTrkyRJ3+T3PkXDpe4UpatQxj85+xjaD2wY= k8s.io/client-go v0.28.4/go.mod h1:0VDZFpgoZfelyP5Wqu0/r/TRYcLYuJ2U1KEeoaPa1N4= -k8s.io/component-base v0.28.3 h1:rDy68eHKxq/80RiMb2Ld/tbH8uAE75JdCqJyi6lXMzI= -k8s.io/component-base v0.28.3/go.mod h1:fDJ6vpVNSk6cRo5wmDa6eKIG7UlIQkaFmZN2fYgIUD8= -k8s.io/klog/v2 v2.100.1 h1:7WCHKK6K8fNhTqfBhISHQ97KrnJNFZMcQvKp7gP/tmg= -k8s.io/klog/v2 v2.100.1/go.mod h1:y1WjHnz7Dj687irZUWR/WLkLc5N1YHtjLdmgWjndZn0= -k8s.io/kube-openapi v0.0.0-20231010175941-2dd684a91f00 h1:aVUu9fTY98ivBPKR9Y5w/AuzbMm96cd3YHRTU83I780= -k8s.io/kube-openapi v0.0.0-20231010175941-2dd684a91f00/go.mod h1:AsvuZPBlUDVuCdzJ87iajxtXuR9oktsTctW/R9wwouA= -k8s.io/utils v0.0.0-20230726121419-3b25d923346b h1:sgn3ZU783SCgtaSJjpcVVlRqd6GSnlTLKgpAAttJvpI= -k8s.io/utils v0.0.0-20230726121419-3b25d923346b/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= +k8s.io/component-base v0.28.4 h1:c/iQLWPdUgI90O+T9TeECg8o7N3YJTiuz2sKxILYcYo= +k8s.io/component-base v0.28.4/go.mod h1:m9hR0uvqXDybiGL2nf/3Lf0MerAfQXzkfWhUY58JUbU= +k8s.io/klog/v2 v2.110.1 h1:U/Af64HJf7FcwMcXyKm2RPM22WZzyR7OSpYj5tg3cL0= +k8s.io/klog/v2 v2.110.1/go.mod h1:YGtd1984u+GgbuZ7e08/yBuAfKLSO0+uR1Fhi6ExXjo= +k8s.io/kube-openapi v0.0.0-20231129212854-f0671cc7e66a h1:ZeIPbyHHqahGIbeyLJJjAUhnxCKqXaDY+n89Ms8szyA= +k8s.io/kube-openapi v0.0.0-20231129212854-f0671cc7e66a/go.mod h1:AsvuZPBlUDVuCdzJ87iajxtXuR9oktsTctW/R9wwouA= +k8s.io/utils v0.0.0-20231127182322-b307cd553661 h1:FepOBzJ0GXm8t0su67ln2wAZjbQ6RxQGZDnzuLcrUTI= +k8s.io/utils v0.0.0-20231127182322-b307cd553661/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= sigs.k8s.io/controller-runtime v0.16.3 h1:2TuvuokmfXvDUamSx1SuAOO3eTyye+47mJCigwG62c4= sigs.k8s.io/controller-runtime v0.16.3/go.mod h1:j7bialYoSn142nv9sCOJmQgDXQXxnroFU4VnX/brVJ0= sigs.k8s.io/controller-tools v0.13.0 h1:NfrvuZ4bxyolhDBt/rCZhDnx3M2hzlhgo5n3Iv2RykI= @@ -282,7 +325,7 @@ sigs.k8s.io/kustomize/kustomize/v5 v5.2.1 h1:bI0UnT+UJiEEl1BaomL71ESl3w5rJo2Aw6C sigs.k8s.io/kustomize/kustomize/v5 v5.2.1/go.mod h1:qzRni4VPV6LxTEY5eC5qH3+995Atdi9E46jiwArROik= sigs.k8s.io/kustomize/kyaml v0.15.0 h1:ynlLMAxDhrY9otSg5GYE2TcIz31XkGZ2Pkj7SdolD84= sigs.k8s.io/kustomize/kyaml v0.15.0/go.mod h1:+uMkBahdU1KNOj78Uta4rrXH+iH7wvg+nW7+GULvREA= -sigs.k8s.io/structured-merge-diff/v4 v4.4.0 h1:ZNWce/g8uFW41cK9XYCj7RRt6919IWznPZ2VOCuHLjg= -sigs.k8s.io/structured-merge-diff/v4 v4.4.0/go.mod h1:N8hJocpFajUSSeSJ9bOZ77VzejKZaXsTtZo4/u7Io08= +sigs.k8s.io/structured-merge-diff/v4 v4.4.1 h1:150L+0vs/8DA78h1u02ooW1/fFq/Lwr+sGiqlzvrtq4= +sigs.k8s.io/structured-merge-diff/v4 v4.4.1/go.mod h1:N8hJocpFajUSSeSJ9bOZ77VzejKZaXsTtZo4/u7Io08= sigs.k8s.io/yaml v1.4.0 h1:Mk1wCc2gy/F0THH0TAp1QYyJNzRm2KCLy3o5ASXVI5E= sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY= diff --git a/main.go b/main.go index a144432..2e9b6b2 100644 --- a/main.go +++ b/main.go @@ -2,16 +2,22 @@ package main import ( "flag" + "fmt" "os" // Import all Kubernetes client auth plugins (e.g. Azure, GCP, OIDC, etc.) // to ensure that exec-entrypoint and run can make use of them. _ "k8s.io/client-go/plugin/pkg/client/auth" + "github.com/Nerzal/gocloak/v13" + "github.com/hashicorp/vault-client-go" lieutenantv1alpha1 "github.com/projectsyn/lieutenant-operator/api/v1alpha1" + "go.uber.org/multierr" + "golang.org/x/oauth2" "k8s.io/apimachinery/pkg/runtime" utilruntime "k8s.io/apimachinery/pkg/util/runtime" clientgoscheme "k8s.io/client-go/kubernetes/scheme" + "k8s.io/client-go/transport" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/healthz" "sigs.k8s.io/controller-runtime/pkg/log/zap" @@ -24,6 +30,19 @@ import ( var ( scheme = runtime.NewScheme() setupLog = ctrl.Log.WithName("setup") + + vaultAddress, vaultToken, vaultTokenFile string + vaultRole, vaultLoginMountPath, vaultKvPath string + + keycloakBaseUrl string + keycloakRealm, keycloakLoginRealm string + keycloakUser, keycloakPassword string + enableLegacyWildFlySupport bool + + clientTemplateFile, clientRoleMappingTemplateFile string + jsonnetImportPaths stringSliceFlag + + keycloakClientIgnorePaths stringSliceFlag ) func init() { @@ -42,14 +61,51 @@ func main() { flag.BoolVar(&enableLeaderElection, "leader-elect", false, "Enable leader election for controller manager. "+ "Enabling this will ensure there is only one active controller manager.") + flag.StringVar(&keycloakBaseUrl, "keycloak-base-url", "", "The base URL of the Keycloak instance") + flag.StringVar(&keycloakRealm, "keycloak-realm", "", "The Keycloak realm to use") + flag.StringVar(&keycloakLoginRealm, "keycloak-login-realm", "", "The Keycloak realm to use for login. If not set, the realm will be used") + flag.StringVar(&keycloakUser, "keycloak-user", "", "The Keycloak user to use") + flag.StringVar(&keycloakPassword, "keycloak-password", "", "The Keycloak password to use") + flag.BoolVar(&enableLegacyWildFlySupport, "keycloak-legacy-wildfly-support", false, "Enable legacy WildFly support for Keycloak") + + flag.StringVar(&vaultAddress, "vault-address", "", "The address of the Vault instance") + flag.StringVar(&vaultToken, "vault-token", "", "The Vault token to use. Takes precedence over vault-token-file.") + flag.StringVar(&vaultTokenFile, "vault-token-file", "", "The file containing the Vault token to use. Usually `/var/run/secrets/kubernetes.io/serviceaccount/token`") + flag.StringVar(&vaultRole, "vault-role", "lieutenant-keycloak-idp-controller", "The Vault role to use.") + flag.StringVar(&vaultLoginMountPath, "vault-login-mount-path", "lieutenant", "The Vault mount path to use for login.") + flag.StringVar(&vaultKvPath, "vault-kv-path", "clusters/kv", "The Vault KV path to use.") + + flag.StringVar(&clientTemplateFile, "client-template-file", "templates/client.jsonnet", "The file containing the client template to use.") + flag.StringVar(&clientRoleMappingTemplateFile, "client-role-mapping-template-file", "templates/client-roles.jsonnet", "The file containing the client role mapping template to use.") + flag.Var(&jsonnetImportPaths, "jsonnet-import-path", "A list of paths to search for Jsonnet libraries when using `import`. Can be specified multiple times.") + + flag.Var(&keycloakClientIgnorePaths, "keycloak-client-ignore-paths", "A list of JSON Pointer strings (RFC 6901) to ignore when checking if changes to the Keycloak client are relevant. Can be specified multiple times. See https://pkg.go.dev/github.com/wI2L/jsondiff@v0.5.0#Ignores") + opts := zap.Options{ Development: true, } opts.BindFlags(flag.CommandLine) flag.Parse() - ctrl.SetLogger(zap.New(zap.UseFlagOptions(&opts))) + flagErrs := []error{} + if keycloakBaseUrl == "" { + flagErrs = append(flagErrs, fmt.Errorf("keycloak-base-url must be set")) + } + if keycloakRealm == "" { + flagErrs = append(flagErrs, fmt.Errorf("keycloak-realm must be set")) + } + if keycloakUser == "" { + flagErrs = append(flagErrs, fmt.Errorf("keycloak-user must be set")) + } + if keycloakPassword == "" { + flagErrs = append(flagErrs, fmt.Errorf("keycloak-password must be set")) + } + if flagErr := multierr.Combine(flagErrs...); flagErr != nil { + setupLog.Error(flagErr, "options are missing") + os.Exit(1) + } + mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{ Scheme: scheme, Metrics: server.Options{ @@ -75,9 +131,63 @@ func main() { os.Exit(1) } + vaultClient, err := vault.New( + vault.WithAddress(vaultAddress), + ) + if err != nil { + setupLog.Error(err, "unable to create vault client") + os.Exit(1) + } + + var vtSource func() (*oauth2.Token, error) + if vaultToken == "" && vaultTokenFile != "" { + vtSource = transport.NewCachedFileTokenSource(vaultTokenFile).Token + } else if vaultToken != "" { + vtSource = func() (*oauth2.Token, error) { + return &oauth2.Token{ + AccessToken: vaultToken, + }, nil + } + } else { + setupLog.Error(err, "vault-token or vault-token-file must be set") + os.Exit(1) + } + + if _, err := os.ReadFile(clientTemplateFile); err != nil { + setupLog.Error(err, "unable to read client template file") + os.Exit(1) + } + if _, err := os.ReadFile(clientRoleMappingTemplateFile); err != nil { + setupLog.Error(err, "unable to read client role mapping template file") + os.Exit(1) + } + + var gcOpts []func(*gocloak.GoCloak) + if enableLegacyWildFlySupport { + gcOpts = append(gcOpts, gocloak.SetLegacyWildFlySupport()) + } if err = (&controllers.ClusterReconciler{ Client: mgr.GetClient(), Scheme: mgr.GetScheme(), + + VaultAuthClient: &vaultClient.Auth, + VaultSecretsClient: &vaultClient.Secrets, + VaultTokenSource: vtSource, + VaultRole: vaultRole, + VaultLoginMountPath: vaultLoginMountPath, + VaultKvPath: vaultKvPath, + + KeycloakClient: gocloak.NewClient(keycloakBaseUrl, gcOpts...), + KeycloakRealm: keycloakRealm, + KeycloakLoginRealm: keycloakLoginRealm, + KeycloakUser: keycloakUser, + KeycloakPassword: keycloakPassword, + + ClientTemplateFile: clientTemplateFile, + ClientRoleMappingTemplateFile: clientRoleMappingTemplateFile, + JsonnetImportPaths: jsonnetImportPaths, + + KeycloakClientIgnorePaths: keycloakClientIgnorePaths, }).SetupWithManager(mgr); err != nil { setupLog.Error(err, "unable to create controller", "controller", "Cluster") os.Exit(1) @@ -99,3 +209,14 @@ func main() { os.Exit(1) } } + +type stringSliceFlag []string + +func (f stringSliceFlag) String() string { + return fmt.Sprint([]string(f)) +} + +func (f *stringSliceFlag) Set(value string) error { + *f = append(*f, value) + return nil +} diff --git a/templates/client-roles.jsonnet b/templates/client-roles.jsonnet new file mode 100644 index 0000000..fb35da5 --- /dev/null +++ b/templates/client-roles.jsonnet @@ -0,0 +1,17 @@ +local context = std.extVar('context'); + +[ + { + role: 'openshiftroot', + group: '/LDAP/VSHN openshiftroot', + }, + { + role: 'openshiftrootswissonly', + }, + { + // https://github.com/sventorben/keycloak-restrict-client-auth#role-based-mode + role: 'restricted-access', + + group: '/LDAP_Customers/Service %s' % context.cluster.metadata.name, + }, +] diff --git a/templates/client.jsonnet b/templates/client.jsonnet new file mode 100644 index 0000000..ce3af67 --- /dev/null +++ b/templates/client.jsonnet @@ -0,0 +1,57 @@ +local context = std.extVar('context'); +local vars = import 'vars.jsonnet'; + +{ + clientId: '%s%s' % [ vars.clientPrefix, context.cluster.metadata.name ], + name: '%s (%s)' % [ context.cluster.spec.displayName, context.cluster.metadata.name ], + description: '', + rootUrl: 'https://oauth-openshift.apps.%s.dev' % context.cluster.metadata.name, + adminUrl: '', + baseUrl: '', + surrogateAuthRequired: false, + enabled: true, + alwaysDisplayInConsole: false, + clientAuthenticatorType: 'client-secret', + redirectUris: [ + '/oauth2/callback', + ], + webOrigins: [], + notBefore: 0, + bearerOnly: false, + consentRequired: false, + standardFlowEnabled: true, + implicitFlowEnabled: false, + directAccessGrantsEnabled: true, + serviceAccountsEnabled: false, + publicClient: false, + frontchannelLogout: true, + protocol: 'openid-connect', + attributes: { + 'oidc.ciba.grant.enabled': 'false', + 'backchannel.logout.session.required': 'true', + 'oauth2.device.authorization.grant.enabled': 'false', + 'display.on.consent.screen': 'false', + 'backchannel.logout.revoke.offline.tokens': 'false', + }, + authenticationFlowBindingOverrides: {}, + fullScopeAllowed: true, + nodeReRegistrationTimeout: -1, + defaultClientScopes: [ + 'web-origins', + 'acr', + 'profile', + 'roles', + 'email', + ], + optionalClientScopes: [ + 'address', + 'phone', + 'offline_access', + 'microprofile-jwt', + ], + access: { + view: true, + configure: true, + manage: true, + }, +} diff --git a/templates/vars.jsonnet b/templates/vars.jsonnet new file mode 100644 index 0000000..e183c7c --- /dev/null +++ b/templates/vars.jsonnet @@ -0,0 +1,3 @@ +{ + clientPrefix: 'cluster_', +} \ No newline at end of file