Preserve admin API configuration when disabling SSO
diff --git a/config/sso_tool.go b/config/sso_tool.go index 573b038..58da2c6 100644 --- a/config/sso_tool.go +++ b/config/sso_tool.go
@@ -40,6 +40,62 @@ var runSSOCommand commandRunner = realCommandRunner +type ssoEnvFromSource struct { + Prefix string `json:"prefix,omitempty"` + ConfigMapRef *ssoLocalObjectReference `json:"configMapRef,omitempty"` + SecretRef *ssoLocalObjectReference `json:"secretRef,omitempty"` +} + +type ssoLocalObjectReference struct { + Name string `json:"name"` + Optional *bool `json:"optional,omitempty"` +} + +type ssoWorkload struct { + Spec struct { + Template struct { + Spec struct { + Containers []struct { + Name string `json:"name"` + EnvFrom []ssoEnvFromSource `json:"envFrom,omitempty"` + } `json:"containers"` + } `json:"spec"` + } `json:"template"` + } `json:"spec"` +} + +type jsonPatchOperation struct { + Op string `json:"op"` + Path string `json:"path"` + Value interface{} `json:"value,omitempty"` +} + +var managedLocalSSOKeys = []string{ + "SSO_ENABLED", + "SSO_PROVIDER", + "SSO_OIDC_ISSUER_URL", + "SSO_OIDC_JWKS_URL", + "SSO_OIDC_AUDIENCE", + "SSO_OIDC_CLIENT_ID", + "SSO_OIDC_REQUIRED_GROUP", + "SSO_OIDC_USERNAME_CLAIM", + "SSO_OIDC_GROUPS_CLAIM", + "SSO_OIDC_CLIENT_SECRET_CONFIGURED", + "SSO_CLIENT_MODE", + "SSO_AUTOPROVISION_ON_LOGIN", + "SSO_AUTOPROVISION_TIMEOUT_SECONDS", + "SSO_AUTOPROVISION_POLL_SECONDS", + "SSO_AUTOPROVISION_DEFAULT_SERVICES", + "SSO_NAMESPACE_PRESERVE_VALID", + "SSO_NAMESPACE_HASH_LENGTH", + "SSO_NAMESPACE_MAX_LENGTH", + "SSO_KUBE_NAMESPACE", + "SSO_KUBE_CONFIGMAP", + "SSO_KUBE_SECRET", + "SSO_KUBE_STATEFULSET", + "SSO_KUBE_CONTAINER", +} + type ssoOptions struct { IssuerURL string JWKSURL string @@ -77,6 +133,14 @@ Configure OpenServerless SSO/OIDC integration for admin-api. +Managed Kubernetes resources: + ConfigMap NAME OIDC_* and SSO_* values created by this command + Secret NAME OIDC_CLIENT_SECRET, when --client-secret is used + admin-api container Exact envFrom references to those two resources + +The command does not manage direct env entries, other envFrom references, +volumes, volumeMounts, or workload annotations. Disable leaves them unchanged. + Options: --username-claim CLAIM OIDC username claim. Default: preferred_username --groups-claim CLAIM OIDC groups claim. Default: groups @@ -133,7 +197,7 @@ } } - if err := patchSSOEnvFrom(opts); err != nil { + if _, err := reconcileSSOEnvFrom(opts, true); err != nil { return err } @@ -310,7 +374,8 @@ if err := removeLocalSSOConfig(configMap); err != nil { return err } - if err := removeSSOEnvFrom(opts); err != nil { + workloadChanged, err := reconcileSSOEnvFrom(opts, false) + if err != nil { return err } if err := deleteSSOConfigMap(opts); err != nil { @@ -319,8 +384,8 @@ if err := deleteSSOSecret(opts); err != nil { return err } - if !opts.NoRollout { - if err := rolloutSSOWorkload(opts); err != nil { + if !opts.NoRollout && workloadChanged { + if err := waitForSSOWorkloadRollout(opts); err != nil { return err } } @@ -330,8 +395,9 @@ } func removeLocalSSOConfig(configMap ConfigMap) error { - for key := range configMap.Flatten() { - if !strings.HasPrefix(key, "SSO_") { + values := configMap.Flatten() + for _, key := range managedLocalSSOKeys { + if _, exists := values[key]; !exists { continue } if err := configMap.Delete(key); err != nil && !strings.Contains(err.Error(), "does not exist in config.json") { @@ -396,64 +462,138 @@ return err } -func patchSSOEnvFrom(opts ssoOptions) error { - envFrom := []map[string]interface{}{ - { - "configMapRef": map[string]string{ - "name": opts.ConfigMapName, - }, - }, +func reconcileSSOEnvFrom(opts ssoOptions, enabled bool) (bool, error) { + output, err := runSSOCommand("kubectl", []string{ + "-n", opts.Namespace, + "get", "statefulset", opts.WorkloadName, + "-o", "json", + }, nil) + if err != nil { + return false, err } - if opts.ClientSecret != "" { - envFrom = append(envFrom, map[string]interface{}{ - "secretRef": map[string]string{ - "name": opts.SecretName, - }, + + var workload ssoWorkload + if err := json.Unmarshal(output, &workload); err != nil { + return false, fmt.Errorf("decode statefulset %s/%s: %w", opts.Namespace, opts.WorkloadName, err) + } + + containerIndex := -1 + var current []ssoEnvFromSource + for index, container := range workload.Spec.Template.Spec.Containers { + if container.Name == opts.ContainerName { + containerIndex = index + current = container.EnvFrom + break + } + } + if containerIndex < 0 { + return false, fmt.Errorf("container %s not found in statefulset %s/%s", opts.ContainerName, opts.Namespace, opts.WorkloadName) + } + + desired := make([]ssoEnvFromSource, 0, 2) + if enabled { + desired = append(desired, ssoEnvFromSource{ + ConfigMapRef: &ssoLocalObjectReference{Name: opts.ConfigMapName}, + }) + if opts.ClientSecret != "" { + desired = append(desired, ssoEnvFromSource{ + SecretRef: &ssoLocalObjectReference{Name: opts.SecretName}, + }) + } + } + + seenDesired := make(map[string]bool) + removeIndexes := make([]int, 0) + for index, source := range current { + key, managed := managedSSOEnvFromKey(source, opts) + if !managed { + continue + } + if enabled && desiredSSOEnvFromKey(key, desired) && !seenDesired[key] { + seenDesired[key] = true + continue + } + removeIndexes = append(removeIndexes, index) + } + + missing := make([]ssoEnvFromSource, 0, len(desired)) + for _, source := range desired { + key, _ := managedSSOEnvFromKey(source, opts) + if !seenDesired[key] { + missing = append(missing, source) + } + } + if len(removeIndexes) == 0 && len(missing) == 0 { + return false, nil + } + + basePath := fmt.Sprintf("/spec/template/spec/containers/%d/envFrom", containerIndex) + operations := make([]jsonPatchOperation, 0, len(removeIndexes)+len(missing)) + for index := len(removeIndexes) - 1; index >= 0; index-- { + removeIndex := removeIndexes[index] + operations = append(operations, jsonPatchOperation{ + Op: "test", + Path: fmt.Sprintf("%s/%d", basePath, removeIndex), + Value: current[removeIndex], + }) + operations = append(operations, jsonPatchOperation{ + Op: "remove", + Path: fmt.Sprintf("%s/%d", basePath, removeIndex), }) } - patch := map[string]interface{}{ - "spec": map[string]interface{}{ - "template": map[string]interface{}{ - "spec": map[string]interface{}{ - "containers": []map[string]interface{}{ - { - "name": opts.ContainerName, - "envFrom": envFrom, - }, - }, - }, - }, - }, + if len(current) == 0 { + operations = append(operations, jsonPatchOperation{ + Op: "add", + Path: basePath, + Value: missing, + }) + } else { + for _, source := range missing { + operations = append(operations, jsonPatchOperation{ + Op: "add", + Path: basePath + "/-", + Value: source, + }) + } } - payload, err := json.Marshal(patch) + + payload, err := json.Marshal(operations) if err != nil { - return err + return false, err } - _, err = runSSOCommand("kubectl", []string{"-n", opts.Namespace, "patch", "statefulset", opts.WorkloadName, "--type=strategic", "-p", string(payload)}, nil) - return err + _, err = runSSOCommand("kubectl", []string{ + "-n", opts.Namespace, + "patch", "statefulset", opts.WorkloadName, + "--type=json", "-p", string(payload), + }, nil) + return err == nil, err } -func removeSSOEnvFrom(opts ssoOptions) error { - patch := map[string]interface{}{ - "spec": map[string]interface{}{ - "template": map[string]interface{}{ - "spec": map[string]interface{}{ - "containers": []map[string]interface{}{ - { - "name": opts.ContainerName, - "envFrom": nil, - }, - }, - }, - }, - }, +func managedSSOEnvFromKey(source ssoEnvFromSource, opts ssoOptions) (string, bool) { + if source.Prefix != "" { + return "", false } - payload, err := json.Marshal(patch) - if err != nil { - return err + if source.ConfigMapRef != nil && source.SecretRef == nil && + source.ConfigMapRef.Name == opts.ConfigMapName && source.ConfigMapRef.Optional == nil { + return "configmap:" + opts.ConfigMapName, true } - _, err = runSSOCommand("kubectl", []string{"-n", opts.Namespace, "patch", "statefulset", opts.WorkloadName, "--type=strategic", "-p", string(payload)}, nil) - return err + if source.SecretRef != nil && source.ConfigMapRef == nil && + source.SecretRef.Name == opts.SecretName && source.SecretRef.Optional == nil { + return "secret:" + opts.SecretName, true + } + return "", false +} + +func desiredSSOEnvFromKey(key string, desired []ssoEnvFromSource) bool { + for _, source := range desired { + if source.ConfigMapRef != nil && key == "configmap:"+source.ConfigMapRef.Name { + return true + } + if source.SecretRef != nil && key == "secret:"+source.SecretRef.Name { + return true + } + } + return false } func deleteSSOConfigMap(opts ssoOptions) error { @@ -474,6 +614,11 @@ return err } +func waitForSSOWorkloadRollout(opts ssoOptions) error { + _, err := runSSOCommand("kubectl", []string{"-n", opts.Namespace, "rollout", "status", "statefulset/" + opts.WorkloadName, "--timeout=180s"}, nil) + return err +} + func ssoClientMode(opts ssoOptions) string { if opts.ClientSecret != "" { return "confidential" @@ -525,8 +670,24 @@ } return output, fmt.Errorf("%s %s failed: %w", name, strings.Join(args, " "), err) } - if len(output) > 0 { + if len(output) > 0 && !isSSOWorkloadJSONGet(name, args) { fmt.Print(string(output)) } return output, nil } + +func isSSOWorkloadJSONGet(name string, args []string) bool { + if name != "kubectl" { + return false + } + for index := 0; index+1 < len(args); index++ { + if args[index] == "get" && index+2 < len(args) && args[index+1] == "statefulset" { + for outputIndex := index + 2; outputIndex+1 < len(args); outputIndex++ { + if args[outputIndex] == "-o" && args[outputIndex+1] == "json" { + return true + } + } + } + } + return false +}
diff --git a/config/sso_tool_test.go b/config/sso_tool_test.go index 910ef48..9f10229 100644 --- a/config/sso_tool_test.go +++ b/config/sso_tool_test.go
@@ -21,6 +21,7 @@ "encoding/json" "os" "path/filepath" + "strconv" "strings" "testing" @@ -33,6 +34,103 @@ stdin string } +func ssoWorkloadJSON(envFrom ...ssoEnvFromSource) []byte { + workload := map[string]interface{}{ + "spec": map[string]interface{}{ + "template": map[string]interface{}{ + "spec": map[string]interface{}{ + "containers": []map[string]interface{}{ + { + "name": defaultSSOContainer, + "envFrom": envFrom, + }, + }, + }, + }, + }, + } + payload, _ := json.Marshal(workload) + return payload +} + +func isSSOWorkloadGet(args []string) bool { + return len(args) >= 7 && args[2] == "get" && args[3] == "statefulset" && args[6] == "json" +} + +func managedConfigMapSource() ssoEnvFromSource { + return ssoEnvFromSource{ConfigMapRef: &ssoLocalObjectReference{Name: defaultSSOConfigMap}} +} + +func managedSecretSource() ssoEnvFromSource { + return ssoEnvFromSource{SecretRef: &ssoLocalObjectReference{Name: defaultSSOSecret}} +} + +type fakeSSOWorkloadState struct { + Env []map[string]string + EnvFrom []ssoEnvFromSource + VolumeMounts []map[string]string + Volumes []map[string]interface{} +} + +func (state *fakeSSOWorkloadState) workloadJSON() []byte { + workload := map[string]interface{}{ + "spec": map[string]interface{}{ + "template": map[string]interface{}{ + "spec": map[string]interface{}{ + "containers": []map[string]interface{}{ + { + "name": defaultSSOContainer, + "env": state.Env, + "envFrom": state.EnvFrom, + "volumeMounts": state.VolumeMounts, + }, + }, + "volumes": state.Volumes, + }, + }, + }, + } + payload, _ := json.Marshal(workload) + return payload +} + +func (state *fakeSSOWorkloadState) applyJSONPatch(payload string) error { + var operations []struct { + Op string `json:"op"` + Path string `json:"path"` + Value json.RawMessage `json:"value"` + } + if err := json.Unmarshal([]byte(payload), &operations); err != nil { + return err + } + for _, operation := range operations { + switch operation.Op { + case "remove": + parts := strings.Split(operation.Path, "/") + index, err := strconv.Atoi(parts[len(parts)-1]) + if err != nil { + return err + } + state.EnvFrom = append(state.EnvFrom[:index], state.EnvFrom[index+1:]...) + case "add": + if strings.HasSuffix(operation.Path, "/-") { + var source ssoEnvFromSource + if err := json.Unmarshal(operation.Value, &source); err != nil { + return err + } + state.EnvFrom = append(state.EnvFrom, source) + continue + } + var sources []ssoEnvFromSource + if err := json.Unmarshal(operation.Value, &sources); err != nil { + return err + } + state.EnvFrom = sources + } + } + return nil +} + func TestConfigSSOToolKeycloak(t *testing.T) { tmpDir := t.TempDir() configPath := filepath.Join(tmpDir, "config.json") @@ -43,6 +141,9 @@ oldRunner := runSSOCommand runSSOCommand = func(name string, args []string, stdin []byte) ([]byte, error) { commands = append(commands, recordedCommand{name: name, args: args, stdin: string(stdin)}) + if isSSOWorkloadGet(args) { + return ssoWorkloadJSON(), nil + } return []byte("ok"), nil } defer func() { runSSOCommand = oldRunner }() @@ -65,9 +166,10 @@ require.Equal(t, true, gotConfig["sso"].(map[string]interface{})["autoprovision"].(map[string]interface{})["on"].(map[string]interface{})["login"]) require.Equal(t, float64(120), gotConfig["sso"].(map[string]interface{})["autoprovision"].(map[string]interface{})["timeout"].(map[string]interface{})["seconds"]) - require.Len(t, commands, 2) + require.Len(t, commands, 3) require.Equal(t, []string{"apply", "-f", "-"}, commands[0].args) - require.Equal(t, []string{"-n", "nuvolaris", "patch", "statefulset", "nuvolaris-system-api", "--type=strategic", "-p", commands[1].args[7]}, commands[1].args) + require.Equal(t, []string{"-n", "nuvolaris", "get", "statefulset", "nuvolaris-system-api", "-o", "json"}, commands[1].args) + require.Equal(t, []string{"-n", "nuvolaris", "patch", "statefulset", "nuvolaris-system-api", "--type=json", "-p", commands[2].args[7]}, commands[2].args) var cmObj map[string]interface{} require.NoError(t, json.Unmarshal([]byte(commands[0].stdin), &cmObj)) @@ -96,6 +198,9 @@ oldRunner := runSSOCommand runSSOCommand = func(name string, args []string, stdin []byte) ([]byte, error) { commands = append(commands, recordedCommand{name: name, args: args, stdin: string(stdin)}) + if isSSOWorkloadGet(args) { + return ssoWorkloadJSON(), nil + } return []byte("ok"), nil } defer func() { runSSOCommand = oldRunner }() @@ -110,11 +215,12 @@ }) require.NoError(t, err) - require.Len(t, commands, 4) + require.Len(t, commands, 5) require.Equal(t, []string{"apply", "-f", "-"}, commands[0].args) - require.Equal(t, []string{"-n", "nuvolaris", "patch", "statefulset", "nuvolaris-system-api", "--type=strategic", "-p", commands[1].args[7]}, commands[1].args) - require.Equal(t, []string{"-n", "nuvolaris", "rollout", "restart", "statefulset/nuvolaris-system-api"}, commands[2].args) - require.Equal(t, []string{"-n", "nuvolaris", "rollout", "status", "statefulset/nuvolaris-system-api", "--timeout=180s"}, commands[3].args) + require.Equal(t, []string{"-n", "nuvolaris", "get", "statefulset", "nuvolaris-system-api", "-o", "json"}, commands[1].args) + require.Equal(t, []string{"-n", "nuvolaris", "patch", "statefulset", "nuvolaris-system-api", "--type=json", "-p", commands[2].args[7]}, commands[2].args) + require.Equal(t, []string{"-n", "nuvolaris", "rollout", "restart", "statefulset/nuvolaris-system-api"}, commands[3].args) + require.Equal(t, []string{"-n", "nuvolaris", "rollout", "status", "statefulset/nuvolaris-system-api", "--timeout=180s"}, commands[4].args) } func TestConfigSSOToolKeycloakWithClientSecret(t *testing.T) { @@ -127,6 +233,9 @@ oldRunner := runSSOCommand runSSOCommand = func(name string, args []string, stdin []byte) ([]byte, error) { commands = append(commands, recordedCommand{name: name, args: args, stdin: string(stdin)}) + if isSSOWorkloadGet(args) { + return ssoWorkloadJSON(), nil + } return []byte("ok"), nil } defer func() { runSSOCommand = oldRunner }() @@ -155,7 +264,7 @@ require.Equal(t, "true", flat["SSO_OIDC_CLIENT_SECRET_CONFIGURED"]) require.Equal(t, "custom-sso-secret", flat["SSO_KUBE_SECRET"]) - require.Len(t, commands, 3) + require.Len(t, commands, 4) var cmObj map[string]interface{} require.NoError(t, json.Unmarshal([]byte(commands[0].stdin), &cmObj)) @@ -172,8 +281,9 @@ secretData := secretObj["stringData"].(map[string]interface{}) require.Equal(t, "super-secret", secretData["OIDC_CLIENT_SECRET"]) - require.Equal(t, []string{"-n", "nuvolaris", "patch", "statefulset", "nuvolaris-system-api", "--type=strategic", "-p", commands[2].args[7]}, commands[2].args) - require.Contains(t, commands[2].args[7], "custom-sso-secret") + require.Equal(t, []string{"-n", "nuvolaris", "get", "statefulset", "nuvolaris-system-api", "-o", "json"}, commands[2].args) + require.Equal(t, []string{"-n", "nuvolaris", "patch", "statefulset", "nuvolaris-system-api", "--type=json", "-p", commands[3].args[7]}, commands[3].args) + require.Contains(t, commands[3].args[7], "custom-sso-secret") } func TestConfigSSOToolKeycloakRequiresValues(t *testing.T) { @@ -196,7 +306,8 @@ "issuer": { "url": "http://issuer" } - } + }, + "external": "preserve-me" } }`), 0644)) @@ -207,6 +318,9 @@ oldRunner := runSSOCommand runSSOCommand = func(name string, args []string, stdin []byte) ([]byte, error) { commands = append(commands, recordedCommand{name: name, args: args, stdin: string(stdin)}) + if isSSOWorkloadGet(args) { + return ssoWorkloadJSON(managedConfigMapSource(), managedSecretSource()), nil + } return []byte("ok"), nil } defer func() { runSSOCommand = oldRunner }() @@ -216,13 +330,19 @@ gotConfig, err := readConfig(configPath, fromConfigJson) require.NoError(t, err) - require.Empty(t, gotConfig) - require.Len(t, commands, 3) - require.Equal(t, []string{"-n", "nuvolaris", "delete", "configmap", "openserverless-sso-config", "--ignore-not-found"}, commands[1].args) - require.Equal(t, []string{"-n", "nuvolaris", "delete", "secret", "openserverless-sso-secret", "--ignore-not-found"}, commands[2].args) + require.Equal(t, map[string]interface{}{ + "sso": map[string]interface{}{ + "external": "preserve-me", + }, + }, gotConfig) + require.Len(t, commands, 4) + require.Equal(t, []string{"-n", "nuvolaris", "get", "statefulset", "nuvolaris-system-api", "-o", "json"}, commands[0].args) + require.Equal(t, []string{"-n", "nuvolaris", "patch", "statefulset", "nuvolaris-system-api", "--type=json", "-p", commands[1].args[7]}, commands[1].args) + require.Equal(t, []string{"-n", "nuvolaris", "delete", "configmap", "openserverless-sso-config", "--ignore-not-found"}, commands[2].args) + require.Equal(t, []string{"-n", "nuvolaris", "delete", "secret", "openserverless-sso-secret", "--ignore-not-found"}, commands[3].args) } -func TestConfigSSOToolDisableRollsOutAdminAPIByDefault(t *testing.T) { +func TestConfigSSOToolDisableWaitsForAdminAPIRolloutByDefault(t *testing.T) { tmpDir := t.TempDir() configPath := filepath.Join(tmpDir, "config.json") cm, err := NewConfigMapBuilder().WithConfigJson(configPath).Build() @@ -232,6 +352,9 @@ oldRunner := runSSOCommand runSSOCommand = func(name string, args []string, stdin []byte) ([]byte, error) { commands = append(commands, recordedCommand{name: name, args: args, stdin: string(stdin)}) + if isSSOWorkloadGet(args) { + return ssoWorkloadJSON(managedConfigMapSource(), managedSecretSource()), nil + } return []byte("ok"), nil } defer func() { runSSOCommand = oldRunner }() @@ -240,9 +363,141 @@ require.NoError(t, err) require.Len(t, commands, 5) - require.Equal(t, []string{"-n", "nuvolaris", "patch", "statefulset", "nuvolaris-system-api", "--type=strategic", "-p", commands[0].args[7]}, commands[0].args) + require.Equal(t, []string{"-n", "nuvolaris", "get", "statefulset", "nuvolaris-system-api", "-o", "json"}, commands[0].args) + require.Equal(t, []string{"-n", "nuvolaris", "patch", "statefulset", "nuvolaris-system-api", "--type=json", "-p", commands[1].args[7]}, commands[1].args) + require.Equal(t, []string{"-n", "nuvolaris", "delete", "configmap", "openserverless-sso-config", "--ignore-not-found"}, commands[2].args) + require.Equal(t, []string{"-n", "nuvolaris", "delete", "secret", "openserverless-sso-secret", "--ignore-not-found"}, commands[3].args) + require.Equal(t, []string{"-n", "nuvolaris", "rollout", "status", "statefulset/nuvolaris-system-api", "--timeout=180s"}, commands[4].args) +} + +func TestConfigSSOToolDisableAlreadyAbsentDoesNotPatchOrRollOut(t *testing.T) { + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "config.json") + cm, err := NewConfigMapBuilder().WithConfigJson(configPath).Build() + require.NoError(t, err) + + foreign := ssoEnvFromSource{ + ConfigMapRef: &ssoLocalObjectReference{Name: "application-config"}, + } + var commands []recordedCommand + oldRunner := runSSOCommand + runSSOCommand = func(name string, args []string, stdin []byte) ([]byte, error) { + commands = append(commands, recordedCommand{name: name, args: args, stdin: string(stdin)}) + if isSSOWorkloadGet(args) { + return ssoWorkloadJSON(foreign), nil + } + return []byte("ok"), nil + } + defer func() { runSSOCommand = oldRunner }() + + require.NoError(t, ConfigSSOTool(cm, []string{"disable"})) + require.Len(t, commands, 3) + require.Equal(t, []string{"-n", "nuvolaris", "get", "statefulset", "nuvolaris-system-api", "-o", "json"}, commands[0].args) require.Equal(t, []string{"-n", "nuvolaris", "delete", "configmap", "openserverless-sso-config", "--ignore-not-found"}, commands[1].args) require.Equal(t, []string{"-n", "nuvolaris", "delete", "secret", "openserverless-sso-secret", "--ignore-not-found"}, commands[2].args) - require.Equal(t, []string{"-n", "nuvolaris", "rollout", "restart", "statefulset/nuvolaris-system-api"}, commands[3].args) - require.Equal(t, []string{"-n", "nuvolaris", "rollout", "status", "statefulset/nuvolaris-system-api", "--timeout=180s"}, commands[4].args) +} + +func TestConfigSSOToolPreservesForeignWorkloadFieldsAcrossEnableDisableEnable(t *testing.T) { + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "config.json") + cm, err := NewConfigMapBuilder().WithConfigJson(configPath).Build() + require.NoError(t, err) + + state := fakeSSOWorkloadState{ + Env: []map[string]string{ + {"name": "APPLICATION_MODE", "value": "production"}, + }, + EnvFrom: []ssoEnvFromSource{ + {ConfigMapRef: &ssoLocalObjectReference{Name: "application-config"}}, + {SecretRef: &ssoLocalObjectReference{Name: "database-credentials"}}, + }, + VolumeMounts: []map[string]string{ + {"name": "application-data", "mountPath": "/data"}, + }, + Volumes: []map[string]interface{}{ + {"name": "application-data", "emptyDir": map[string]interface{}{}}, + }, + } + originalEnv, err := json.Marshal(state.Env) + require.NoError(t, err) + originalMounts, err := json.Marshal(state.VolumeMounts) + require.NoError(t, err) + originalVolumes, err := json.Marshal(state.Volumes) + require.NoError(t, err) + + var commands []recordedCommand + var patchPayloads []string + oldRunner := runSSOCommand + runSSOCommand = func(name string, args []string, stdin []byte) ([]byte, error) { + commands = append(commands, recordedCommand{name: name, args: args, stdin: string(stdin)}) + if isSSOWorkloadGet(args) { + return state.workloadJSON(), nil + } + if len(args) >= 8 && args[2] == "patch" && args[5] == "--type=json" { + patchPayloads = append(patchPayloads, args[7]) + if err := state.applyJSONPatch(args[7]); err != nil { + return nil, err + } + } + return []byte("ok"), nil + } + defer func() { runSSOCommand = oldRunner }() + + enableArgs := []string{ + "keycloak", + "--enable", + "--issuer-url", "https://keycloak.example.test/realms/openserverless", + "--jwks-url", "https://keycloak.example.test/realms/openserverless/protocol/openid-connect/certs", + "--client-id", "openserverless-admin-api", + "--client-secret", "test-secret", + "--required-group", "openserverless-users", + "--no-rollout", + } + require.NoError(t, ConfigSSOTool(cm, enableArgs)) + require.Equal(t, []ssoEnvFromSource{ + {ConfigMapRef: &ssoLocalObjectReference{Name: "application-config"}}, + {SecretRef: &ssoLocalObjectReference{Name: "database-credentials"}}, + managedConfigMapSource(), + managedSecretSource(), + }, state.EnvFrom) + + require.NoError(t, ConfigSSOTool(cm, []string{"disable", "--no-rollout"})) + require.Equal(t, []ssoEnvFromSource{ + {ConfigMapRef: &ssoLocalObjectReference{Name: "application-config"}}, + {SecretRef: &ssoLocalObjectReference{Name: "database-credentials"}}, + }, state.EnvFrom) + require.Len(t, patchPayloads, 2) + require.JSONEq(t, `[ + {"op":"test","path":"/spec/template/spec/containers/0/envFrom/3","value":{"secretRef":{"name":"openserverless-sso-secret"}}}, + {"op":"remove","path":"/spec/template/spec/containers/0/envFrom/3"}, + {"op":"test","path":"/spec/template/spec/containers/0/envFrom/2","value":{"configMapRef":{"name":"openserverless-sso-config"}}}, + {"op":"remove","path":"/spec/template/spec/containers/0/envFrom/2"} +]`, patchPayloads[1]) + + // Repeating disable is idempotent: resources are deleted with + // --ignore-not-found and no StatefulSet patch or rollout is produced. + require.NoError(t, ConfigSSOTool(cm, []string{"disable", "--no-rollout"})) + require.Len(t, patchPayloads, 2) + + require.NoError(t, ConfigSSOTool(cm, enableArgs)) + require.Equal(t, []ssoEnvFromSource{ + {ConfigMapRef: &ssoLocalObjectReference{Name: "application-config"}}, + {SecretRef: &ssoLocalObjectReference{Name: "database-credentials"}}, + managedConfigMapSource(), + managedSecretSource(), + }, state.EnvFrom) + require.Len(t, patchPayloads, 3) + + currentEnv, err := json.Marshal(state.Env) + require.NoError(t, err) + currentMounts, err := json.Marshal(state.VolumeMounts) + require.NoError(t, err) + currentVolumes, err := json.Marshal(state.Volumes) + require.NoError(t, err) + require.JSONEq(t, string(originalEnv), string(currentEnv)) + require.JSONEq(t, string(originalMounts), string(currentMounts)) + require.JSONEq(t, string(originalVolumes), string(currentVolumes)) + for _, command := range commands { + require.NotContains(t, command.args, "rollout") + } }
diff --git a/docs/SSO.md b/docs/SSO.md new file mode 100644 index 0000000..a91f7c9 --- /dev/null +++ b/docs/SSO.md
@@ -0,0 +1,70 @@ +<!-- + ~ Licensed to the Apache Software Foundation (ASF) under one + ~ or more contributor license agreements. See the NOTICE file + ~ distributed with this work for additional information + ~ regarding copyright ownership. The ASF licenses this file + ~ to you under the Apache License, Version 2.0 (the + ~ "License"); you may not use this file except in compliance + ~ with the License. You may obtain a copy of the License at + ~ + ~ http://www.apache.org/licenses/LICENSE-2.0 + ~ + ~ Unless required by applicable law or agreed to in writing, + ~ software distributed under the License is distributed on an + ~ "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + ~ KIND, either express or implied. See the License for the + ~ specific language governing permissions and limitations + ~ under the License. + --> + +# SSO configuration ownership + +`ops config sso keycloak --enable` manages the following configuration. The +resource names can be changed with `--configmap`, `--secret`, `--statefulset`, +and `--container`. + +## Kubernetes resources + +The command creates and owns a dedicated ConfigMap. Its default name is +`openserverless-sso-config`, and it contains exactly these keys: + +- `OIDC_ISSUER_URL` +- `OIDC_JWKS_URL` +- `OIDC_AUDIENCE` +- `OIDC_CLIENT_ID` +- `OIDC_REQUIRED_GROUP` +- `OIDC_USERNAME_CLAIM` +- `OIDC_GROUPS_CLAIM` +- `SSO_AUTOPROVISION_ON_LOGIN` +- `SSO_AUTOPROVISION_TIMEOUT_SECONDS` +- `SSO_AUTOPROVISION_POLL_SECONDS` +- `SSO_AUTOPROVISION_DEFAULT_SERVICES` +- `SSO_NAMESPACE_PRESERVE_VALID` +- `SSO_NAMESPACE_HASH_LENGTH` +- `SSO_NAMESPACE_MAX_LENGTH` + +When `--client-secret` is supplied, the command also creates and owns a +dedicated Secret. Its default name is `openserverless-sso-secret`, and its only +managed key is `OIDC_CLIENT_SECRET`. + +The command adds exact, prefix-free `envFrom` references for the managed +ConfigMap and, when applicable, the managed Secret to the selected admin-api +container. It does not create direct `env` entries, volumes, volume mounts, or +annotations. + +## Disable behavior + +`ops config sso disable` removes only the exact `envFrom` references described +above and deletes the two dedicated resources with Kubernetes +`--ignore-not-found`. Other `envFrom` entries, all direct `env` entries, +volumes, volume mounts, and existing annotations remain unchanged. + +The command is idempotent. If neither managed reference is present, no patch or +rollout command is issued for the StatefulSet. Missing ConfigMaps and Secrets +are not errors. Removing an `envFrom` entry changes the pod template, so +Kubernetes starts the necessary rollout itself. By default the command waits +for that rollout; `--no-rollout` skips the wait and does not issue an additional +restart. + +The local `~/.ops/config.json` cleanup is also limited to the keys written by +the SSO command. Unrecognized `SSO_*` keys are preserved.