blob: 9e533ea750dd78f82a6fd82fd1ea625f02e03947 [file]
// 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.
package config
import (
"bytes"
"encoding/json"
"flag"
"fmt"
"os"
"os/exec"
"sort"
"strings"
)
const (
defaultSSONamespace = "nuvolaris"
defaultSSOConfigMap = "openserverless-sso-config"
defaultSSOSecret = "openserverless-sso-secret"
defaultSSOWorkload = "nuvolaris-system-api"
defaultSSOContainer = "nuvolaris-system-api"
)
type commandRunner func(name string, args []string, stdin []byte) ([]byte, error)
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
Audience string
ClientID string
ClientSecret string
RequiredGroup string
UsernameClaim string
GroupsClaim string
AutoProvision bool
AutoProvisionTimeout string
AutoProvisionPoll string
AutoProvisionServices string
NamespacePreserveValid bool
NamespaceHashLength string
NamespaceMaxLength string
Namespace string
ConfigMapName string
SecretName string
WorkloadName string
ContainerName string
NoRollout bool
}
func printSSOUsage() {
fmt.Print(`Usage:
ops -config sso keycloak --enable --issuer-url URL --jwks-url URL (--audience AUDIENCE|--client-id CLIENT_ID) --required-group GROUP [options]
ops -config sso show
ops -config sso disable [options]
Legacy compatibility tool for OpenServerless SSO/OIDC integration.
The public command surface is provided by the ops config sso task.
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
--client-id CLIENT_ID OIDC client id. Defaults to --audience when omitted
--client-secret SECRET OIDC confidential client secret stored only in Kubernetes Secret
--namespace NS Kubernetes namespace. Default: nuvolaris
--configmap NAME Kubernetes ConfigMap name. Default: openserverless-sso-config
--secret NAME Kubernetes Secret name. Default: openserverless-sso-secret
--statefulset NAME admin-api StatefulSet name. Default: nuvolaris-system-api
--container NAME admin-api container name. Default: nuvolaris-system-api
--no-rollout Do not restart or wait for admin-api rollout
`)
}
func ConfigSSOTool(configMap ConfigMap, args []string) error {
if len(args) == 0 {
printSSOUsage()
return nil
}
switch args[0] {
case "keycloak":
return configureKeycloakSSO(configMap, args[1:])
case "show":
printSSOConfig(configMap)
return nil
case "disable":
return disableSSO(configMap, args[1:])
case "-h", "--help", "help":
printSSOUsage()
return nil
default:
return fmt.Errorf("unknown sso command: %s", args[0])
}
}
func configureKeycloakSSO(configMap ConfigMap, args []string) error {
opts, err := parseKeycloakSSOArgs(args)
if err != nil {
return err
}
if err := saveSSOConfig(configMap, opts); err != nil {
return err
}
if err := applySSOConfigMap(opts); err != nil {
return err
}
if opts.ClientSecret != "" {
if err := applySSOSecret(opts); err != nil {
return err
}
}
if _, err := reconcileSSOEnvFrom(opts, true); err != nil {
return err
}
if !opts.NoRollout {
if err := rolloutSSOWorkload(opts); err != nil {
return err
}
}
fmt.Println("SSO configuration applied to admin-api.")
fmt.Printf("ConfigMap: %s/%s\n", opts.Namespace, opts.ConfigMapName)
if opts.ClientSecret != "" {
fmt.Printf("Secret: %s/%s\n", opts.Namespace, opts.SecretName)
}
return nil
}
func parseKeycloakSSOArgs(args []string) (ssoOptions, error) {
opts := ssoOptions{
UsernameClaim: "preferred_username",
GroupsClaim: "groups",
AutoProvision: true,
AutoProvisionTimeout: "120",
AutoProvisionPoll: "2",
AutoProvisionServices: "all",
NamespacePreserveValid: true,
NamespaceHashLength: "8",
NamespaceMaxLength: "61",
Namespace: defaultSSONamespace,
ConfigMapName: defaultSSOConfigMap,
SecretName: defaultSSOSecret,
WorkloadName: defaultSSOWorkload,
ContainerName: defaultSSOContainer,
}
flags := flag.NewFlagSet("sso keycloak", flag.ContinueOnError)
flags.SetOutput(os.Stderr)
enable := flags.Bool("enable", false, "enable SSO")
flags.StringVar(&opts.IssuerURL, "issuer-url", "", "OIDC issuer URL")
flags.StringVar(&opts.JWKSURL, "jwks-url", "", "OIDC JWKS URL")
flags.StringVar(&opts.Audience, "audience", "", "OIDC audience")
flags.StringVar(&opts.ClientID, "client-id", "", "OIDC client id")
flags.StringVar(&opts.ClientSecret, "client-secret", "", "OIDC confidential client secret")
flags.StringVar(&opts.RequiredGroup, "required-group", "", "required OIDC group")
flags.StringVar(&opts.UsernameClaim, "username-claim", opts.UsernameClaim, "OIDC username claim")
flags.StringVar(&opts.GroupsClaim, "groups-claim", opts.GroupsClaim, "OIDC groups claim")
flags.StringVar(&opts.Namespace, "namespace", opts.Namespace, "Kubernetes namespace")
flags.StringVar(&opts.ConfigMapName, "configmap", opts.ConfigMapName, "Kubernetes ConfigMap name")
flags.StringVar(&opts.SecretName, "secret", opts.SecretName, "Kubernetes Secret name")
flags.StringVar(&opts.WorkloadName, "statefulset", opts.WorkloadName, "admin-api StatefulSet name")
flags.StringVar(&opts.ContainerName, "container", opts.ContainerName, "admin-api container name")
flags.BoolVar(&opts.NoRollout, "no-rollout", false, "skip rollout restart/status")
if err := flags.Parse(args); err != nil {
return opts, err
}
if !*enable {
return opts, fmt.Errorf("missing --enable")
}
if flags.NArg() > 0 {
return opts, fmt.Errorf("unexpected arguments: %s", strings.Join(flags.Args(), " "))
}
if opts.IssuerURL == "" {
return opts, fmt.Errorf("missing --issuer-url")
}
if opts.JWKSURL == "" {
return opts, fmt.Errorf("missing --jwks-url")
}
if opts.ClientID == "" {
opts.ClientID = opts.Audience
}
if opts.Audience == "" {
opts.Audience = opts.ClientID
}
if opts.Audience == "" {
return opts, fmt.Errorf("missing --audience or --client-id")
}
if opts.RequiredGroup == "" {
return opts, fmt.Errorf("missing --required-group")
}
if opts.UsernameClaim == "" {
return opts, fmt.Errorf("missing --username-claim")
}
if opts.GroupsClaim == "" {
return opts, fmt.Errorf("missing --groups-claim")
}
if opts.SecretName == "" {
return opts, fmt.Errorf("missing --secret")
}
return opts, nil
}
func parseDisableSSOArgs(args []string) (ssoOptions, error) {
opts := ssoOptions{
Namespace: defaultSSONamespace,
ConfigMapName: defaultSSOConfigMap,
SecretName: defaultSSOSecret,
WorkloadName: defaultSSOWorkload,
ContainerName: defaultSSOContainer,
}
flags := flag.NewFlagSet("sso disable", flag.ContinueOnError)
flags.SetOutput(os.Stderr)
flags.StringVar(&opts.Namespace, "namespace", opts.Namespace, "Kubernetes namespace")
flags.StringVar(&opts.ConfigMapName, "configmap", opts.ConfigMapName, "Kubernetes ConfigMap name")
flags.StringVar(&opts.SecretName, "secret", opts.SecretName, "Kubernetes Secret name")
flags.StringVar(&opts.WorkloadName, "statefulset", opts.WorkloadName, "admin-api StatefulSet name")
flags.StringVar(&opts.ContainerName, "container", opts.ContainerName, "admin-api container name")
flags.BoolVar(&opts.NoRollout, "no-rollout", false, "skip rollout restart/status")
if err := flags.Parse(args); err != nil {
return opts, err
}
if flags.NArg() > 0 {
return opts, fmt.Errorf("unexpected arguments: %s", strings.Join(flags.Args(), " "))
}
return opts, nil
}
func saveSSOConfig(configMap ConfigMap, opts ssoOptions) error {
values := map[string]string{
"SSO_ENABLED": "true",
"SSO_PROVIDER": "keycloak",
"SSO_OIDC_ISSUER_URL": opts.IssuerURL,
"SSO_OIDC_JWKS_URL": opts.JWKSURL,
"SSO_OIDC_AUDIENCE": opts.Audience,
"SSO_OIDC_CLIENT_ID": opts.ClientID,
"SSO_OIDC_REQUIRED_GROUP": opts.RequiredGroup,
"SSO_OIDC_USERNAME_CLAIM": opts.UsernameClaim,
"SSO_OIDC_GROUPS_CLAIM": opts.GroupsClaim,
"SSO_OIDC_CLIENT_SECRET_CONFIGURED": fmt.Sprintf("%t", opts.ClientSecret != ""),
"SSO_CLIENT_MODE": ssoClientMode(opts),
"SSO_AUTOPROVISION_ON_LOGIN": fmt.Sprintf("%t", opts.AutoProvision),
"SSO_AUTOPROVISION_TIMEOUT_SECONDS": opts.AutoProvisionTimeout,
"SSO_AUTOPROVISION_POLL_SECONDS": opts.AutoProvisionPoll,
"SSO_AUTOPROVISION_DEFAULT_SERVICES": opts.AutoProvisionServices,
"SSO_NAMESPACE_PRESERVE_VALID": fmt.Sprintf("%t", opts.NamespacePreserveValid),
"SSO_NAMESPACE_HASH_LENGTH": opts.NamespaceHashLength,
"SSO_NAMESPACE_MAX_LENGTH": opts.NamespaceMaxLength,
"SSO_KUBE_NAMESPACE": opts.Namespace,
"SSO_KUBE_CONFIGMAP": opts.ConfigMapName,
"SSO_KUBE_SECRET": opts.SecretName,
"SSO_KUBE_STATEFULSET": opts.WorkloadName,
"SSO_KUBE_CONTAINER": opts.ContainerName,
}
for key, value := range values {
if err := configMap.Insert(key, value); err != nil {
return err
}
}
return configMap.SaveConfig()
}
func printSSOConfig(configMap ConfigMap) {
values := configMap.Flatten()
keys := make([]string, 0)
for key := range values {
if strings.HasPrefix(key, "SSO_") {
keys = append(keys, key)
}
}
sort.Strings(keys)
for _, key := range keys {
fmt.Printf("%s=%s\n", key, printableSSOValue(key, values[key]))
}
}
func disableSSO(configMap ConfigMap, args []string) error {
opts, err := parseDisableSSOArgs(args)
if err != nil {
return err
}
if err := removeLocalSSOConfig(configMap); err != nil {
return err
}
workloadChanged, err := reconcileSSOEnvFrom(opts, false)
if err != nil {
return err
}
if err := deleteSSOConfigMap(opts); err != nil {
return err
}
if err := deleteSSOSecret(opts); err != nil {
return err
}
if !opts.NoRollout && workloadChanged {
if err := waitForSSOWorkloadRollout(opts); err != nil {
return err
}
}
fmt.Println("SSO configuration disabled for admin-api.")
return nil
}
func removeLocalSSOConfig(configMap ConfigMap) error {
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") {
return err
}
}
return configMap.SaveConfig()
}
func applySSOConfigMap(opts ssoOptions) error {
data := map[string]string{
"OIDC_ISSUER_URL": opts.IssuerURL,
"OIDC_JWKS_URL": opts.JWKSURL,
"OIDC_AUDIENCE": opts.Audience,
"OIDC_CLIENT_ID": opts.ClientID,
"OIDC_REQUIRED_GROUP": opts.RequiredGroup,
"OIDC_USERNAME_CLAIM": opts.UsernameClaim,
"OIDC_GROUPS_CLAIM": opts.GroupsClaim,
"SSO_AUTOPROVISION_ON_LOGIN": fmt.Sprintf("%t", opts.AutoProvision),
"SSO_AUTOPROVISION_TIMEOUT_SECONDS": opts.AutoProvisionTimeout,
"SSO_AUTOPROVISION_POLL_SECONDS": opts.AutoProvisionPoll,
"SSO_AUTOPROVISION_DEFAULT_SERVICES": opts.AutoProvisionServices,
"SSO_NAMESPACE_PRESERVE_VALID": fmt.Sprintf("%t", opts.NamespacePreserveValid),
"SSO_NAMESPACE_HASH_LENGTH": opts.NamespaceHashLength,
"SSO_NAMESPACE_MAX_LENGTH": opts.NamespaceMaxLength,
}
obj := map[string]interface{}{
"apiVersion": "v1",
"kind": "ConfigMap",
"metadata": map[string]string{
"name": opts.ConfigMapName,
"namespace": opts.Namespace,
},
"data": data,
}
payload, err := json.Marshal(obj)
if err != nil {
return err
}
_, err = runSSOCommand("kubectl", []string{"apply", "-f", "-"}, payload)
return err
}
func applySSOSecret(opts ssoOptions) error {
obj := map[string]interface{}{
"apiVersion": "v1",
"kind": "Secret",
"metadata": map[string]string{
"name": opts.SecretName,
"namespace": opts.Namespace,
},
"type": "Opaque",
"stringData": map[string]string{
"OIDC_CLIENT_SECRET": opts.ClientSecret,
},
}
payload, err := json.Marshal(obj)
if err != nil {
return err
}
_, err = runSSOCommand("kubectl", []string{"apply", "-f", "-"}, payload)
return err
}
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
}
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),
})
}
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(operations)
if err != nil {
return false, err
}
_, err = runSSOCommand("kubectl", []string{
"-n", opts.Namespace,
"patch", "statefulset", opts.WorkloadName,
"--type=json", "-p", string(payload),
}, nil)
return err == nil, err
}
func managedSSOEnvFromKey(source ssoEnvFromSource, opts ssoOptions) (string, bool) {
if source.Prefix != "" {
return "", false
}
if source.ConfigMapRef != nil && source.SecretRef == nil &&
source.ConfigMapRef.Name == opts.ConfigMapName && source.ConfigMapRef.Optional == nil {
return "configmap:" + opts.ConfigMapName, true
}
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 {
_, err := runSSOCommand("kubectl", []string{"-n", opts.Namespace, "delete", "configmap", opts.ConfigMapName, "--ignore-not-found"}, nil)
return err
}
func deleteSSOSecret(opts ssoOptions) error {
_, err := runSSOCommand("kubectl", []string{"-n", opts.Namespace, "delete", "secret", opts.SecretName, "--ignore-not-found"}, nil)
return err
}
func rolloutSSOWorkload(opts ssoOptions) error {
if _, err := runSSOCommand("kubectl", []string{"-n", opts.Namespace, "rollout", "restart", "statefulset/" + opts.WorkloadName}, nil); err != nil {
return err
}
_, err := runSSOCommand("kubectl", []string{"-n", opts.Namespace, "rollout", "status", "statefulset/" + opts.WorkloadName, "--timeout=180s"}, nil)
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"
}
return "public"
}
func printableSSOValue(key string, value interface{}) interface{} {
if isSecretLikeKey(key) {
return "<redacted>"
}
return value
}
func isSecretLikeKey(key string) bool {
lower := strings.ToLower(key)
for _, marker := range []string{"secret", "token", "credential", "password"} {
if strings.Contains(lower, marker) && !strings.HasSuffix(lower, "_configured") {
return true
}
}
return false
}
func realCommandRunner(name string, args []string, stdin []byte) ([]byte, error) {
cmdName := name
if _, err := exec.LookPath(cmdName); err != nil && name == "kubectl" {
if home, homeErr := os.UserHomeDir(); homeErr == nil {
opsKubectl := home + "/.ops/linux-amd64/bin/kubectl"
if _, statErr := os.Stat(opsKubectl); statErr == nil {
cmdName = opsKubectl
}
}
}
cmd := exec.Command(cmdName, args...)
if stdin != nil {
cmd.Stdin = bytes.NewReader(stdin)
}
var stdout bytes.Buffer
var stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
err := cmd.Run()
output := append(stdout.Bytes(), stderr.Bytes()...)
if err != nil {
if len(output) > 0 {
return output, fmt.Errorf("%s %s failed: %w: %s", name, strings.Join(args, " "), err, strings.TrimSpace(string(output)))
}
return output, fmt.Errorf("%s %s failed: %w", name, strings.Join(args, " "), err)
}
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
}