diff --git a/api/v1/mdb/mongodb_types.go b/api/v1/mdb/mongodb_types.go index 58ea251fe..705111f7c 100644 --- a/api/v1/mdb/mongodb_types.go +++ b/api/v1/mdb/mongodb_types.go @@ -811,6 +811,7 @@ func (s *Security) IsOIDCEnabled() bool { if s == nil || s.Authentication == nil || !s.Authentication.Enabled { return false } + return s.Authentication.IsOIDCEnabled() } diff --git a/controllers/om/automation_config.go b/controllers/om/automation_config.go index 9dffdffdb..951382c0f 100644 --- a/controllers/om/automation_config.go +++ b/controllers/om/automation_config.go @@ -8,6 +8,7 @@ import ( "k8s.io/apimachinery/pkg/api/equality" "github.com/mongodb/mongodb-kubernetes/controllers/operator/ldap" + "github.com/mongodb/mongodb-kubernetes/controllers/operator/oidc" "github.com/mongodb/mongodb-kubernetes/pkg/util" "github.com/mongodb/mongodb-kubernetes/pkg/util/generate" "github.com/mongodb/mongodb-kubernetes/pkg/util/maputil" @@ -20,10 +21,11 @@ import ( // configuration which are merged into the `Deployment` object before sending it back to Ops Manager. // As of right now only support configuring LogRotate for monitoring and backup via dedicated endpoints. type AutomationConfig struct { - Auth *Auth - AgentSSL *AgentSSL - Deployment Deployment - Ldap *ldap.Ldap + Auth *Auth + AgentSSL *AgentSSL + Deployment Deployment + Ldap *ldap.Ldap + OIDCProviderConfigs []oidc.ProviderConfig } // Apply merges the state of all concrete structs into the Deployment (map[string]interface{}) @@ -58,9 +60,67 @@ func applyInto(a AutomationConfig, into *Deployment) error { } (*into)["ldap"] = mergedLdap } + + if len(a.OIDCProviderConfigs) > 0 { + deploymentConfigs := make([]map[string]any, 0) + if configs, ok := a.Deployment["oidcProviderConfigs"]; ok { + configsSlice := cast.ToSlice(configs) + for _, config := range configsSlice { + deploymentConfigs = append(deploymentConfigs, config.(map[string]any)) + } + } + + result := make([]map[string]any, 0) + for _, config := range a.OIDCProviderConfigs { + deploymentConfig := findOrCreateEmptyDeploymentConfig(deploymentConfigs, config.AuthNamePrefix) + + deploymentConfig["authNamePrefix"] = config.AuthNamePrefix + deploymentConfig["audience"] = config.Audience + deploymentConfig["issuerUri"] = config.IssuerUri + deploymentConfig["userClaim"] = config.UserClaim + deploymentConfig["supportsHumanFlows"] = config.SupportsHumanFlows + deploymentConfig["useAuthorizationClaim"] = config.UseAuthorizationClaim + + if config.ClientId == util.MergoDelete { + delete(deploymentConfig, "clientId") + } else { + deploymentConfig["clientId"] = config.ClientId + } + + if len(config.RequestedScopes) == 0 { + delete(deploymentConfig, "requestedScopes") + } else { + deploymentConfig["requestedScopes"] = config.RequestedScopes + } + + if config.GroupsClaim == util.MergoDelete { + delete(deploymentConfig, "groupsClaim") + } else { + deploymentConfig["groupsClaim"] = config.GroupsClaim + } + + result = append(result, deploymentConfig) + } + + (*into)["oidcProviderConfigs"] = result + } else { + // Clear oidcProviderConfigs if no configs are provided + delete(*into, "oidcProviderConfigs") + } + return nil } +func findOrCreateEmptyDeploymentConfig(deploymentConfigs []map[string]any, configName string) map[string]any { + for _, deploymentConfig := range deploymentConfigs { + if configName == deploymentConfig["authNamePrefix"] { + return deploymentConfig + } + } + + return make(map[string]any) +} + // EqualsWithoutDeployment returns true if two AutomationConfig objects are meaningful equal by following the following conditions: // - Not taking AutomationConfig.Deployment into consideration. // - Serializing ac A and ac B to ensure that we remove util.MergoDelete before comparing those two. @@ -432,6 +492,19 @@ func BuildAutomationConfigFromDeployment(deployment Deployment) (*AutomationConf finalAutomationConfig.Ldap = acLdap } + oidcConfigsArray, ok := deployment["oidcProviderConfigs"] + if ok { + oidcMarshalled, err := json.Marshal(oidcConfigsArray) + if err != nil { + return nil, err + } + providerConfigs := make([]oidc.ProviderConfig, 0) + if err := json.Unmarshal(oidcMarshalled, &providerConfigs); err != nil { + return nil, err + } + finalAutomationConfig.OIDCProviderConfigs = providerConfigs + } + return finalAutomationConfig, nil } diff --git a/controllers/om/automation_config_test.go b/controllers/om/automation_config_test.go index 0b991ef0b..fb4875636 100644 --- a/controllers/om/automation_config_test.go +++ b/controllers/om/automation_config_test.go @@ -9,6 +9,7 @@ import ( "k8s.io/apimachinery/pkg/api/equality" "github.com/mongodb/mongodb-kubernetes/controllers/operator/ldap" + "github.com/mongodb/mongodb-kubernetes/controllers/operator/oidc" "github.com/mongodb/mongodb-kubernetes/pkg/util" ) @@ -627,6 +628,54 @@ func TestAutomationConfigEquality(t *testing.T) { } ldapConfig2 := ldapConfig + oidcProviderConfigs := []oidc.ProviderConfig{ + { + AuthNamePrefix: "provider-1", + Audience: "aud", + IssuerUri: "https://provider1.okta.com", + UserClaim: "sub", + GroupsClaim: "groups", + SupportsHumanFlows: false, + UseAuthorizationClaim: true, + }, + { + AuthNamePrefix: "provider-2", + Audience: "aud", + IssuerUri: "https://provider2.okta.com", + ClientId: "provider2-clientId", + RequestedScopes: []string{"openid", "profile"}, + UserClaim: "sub", + SupportsHumanFlows: true, + UseAuthorizationClaim: false, + }, + } + + oidcProviderConfigsWithMergoDelete := []oidc.ProviderConfig{ + { + AuthNamePrefix: "provider-1", + Audience: "aud", + IssuerUri: "https://provider1.okta.com", + ClientId: util.MergoDelete, + UserClaim: "sub", + GroupsClaim: "groups", + SupportsHumanFlows: false, + UseAuthorizationClaim: true, + }, + { + AuthNamePrefix: "provider-2", + Audience: "aud", + IssuerUri: "https://provider2.okta.com", + ClientId: "provider2-clientId", + RequestedScopes: []string{"openid", "profile"}, + UserClaim: "sub", + GroupsClaim: util.MergoDelete, + SupportsHumanFlows: true, + UseAuthorizationClaim: false, + }, + } + + oidcProviderConfigs2 := oidcProviderConfigs + tests := map[string]struct { a *AutomationConfig b *AutomationConfig @@ -653,29 +702,33 @@ func TestAutomationConfigEquality(t *testing.T) { }, "Two the same configs created using the same structs are the same": { a: &AutomationConfig{ - Auth: &authConfig, - AgentSSL: &agentSSLConfig, - Deployment: deployment1, - Ldap: &ldapConfig, + Auth: &authConfig, + AgentSSL: &agentSSLConfig, + Deployment: deployment1, + Ldap: &ldapConfig, + OIDCProviderConfigs: oidcProviderConfigs, }, b: &AutomationConfig{ - Auth: &authConfig, - AgentSSL: &agentSSLConfig, - Deployment: deployment1, - Ldap: &ldapConfig, + Auth: &authConfig, + AgentSSL: &agentSSLConfig, + Deployment: deployment1, + Ldap: &ldapConfig, + OIDCProviderConfigs: oidcProviderConfigs, }, expectedEquality: true, }, "Two the same configs created using deep copy (and structs with different addresses) are the same": { a: &AutomationConfig{ - Auth: &authConfig, - AgentSSL: &agentSSLConfig, - Ldap: &ldapConfig, + Auth: &authConfig, + AgentSSL: &agentSSLConfig, + Ldap: &ldapConfig, + OIDCProviderConfigs: oidcProviderConfigs, }, b: &AutomationConfig{ - Auth: &authConfig2, - AgentSSL: &agentSSLConfig2, - Ldap: &ldapConfig2, + Auth: &authConfig2, + AgentSSL: &agentSSLConfig2, + Ldap: &ldapConfig2, + OIDCProviderConfigs: oidcProviderConfigs2, }, expectedEquality: true, }, @@ -685,7 +738,8 @@ func TestAutomationConfigEquality(t *testing.T) { NewAutoPwd: util.MergoDelete, LdapGroupDN: "abc", }, - Ldap: &ldapConfig, + Ldap: &ldapConfig, + OIDCProviderConfigs: oidcProviderConfigs, }, b: &AutomationConfig{ Auth: &Auth{ @@ -694,7 +748,8 @@ func TestAutomationConfigEquality(t *testing.T) { AgentSSL: &AgentSSL{ AutoPEMKeyFilePath: util.MergoDelete, }, - Ldap: &ldapConfig2, + Ldap: &ldapConfig2, + OIDCProviderConfigs: oidcProviderConfigsWithMergoDelete, }, expectedEquality: true, }, @@ -783,6 +838,204 @@ func TestLDAPIsMerged(t *testing.T) { assert.Contains(t, ldapMap, "CAFileContents") } +func TestOIDCProviderConfigsAreMerged(t *testing.T) { + tests := map[string]struct { + providerConfigs []oidc.ProviderConfig + expectedProviderConfigs any + }{ + "nil config list clears deployment oidcProviderConfigs": { + providerConfigs: nil, + expectedProviderConfigs: nil, + }, + "empty config list clears deployment oidcProviderConfigs": { + providerConfigs: make([]oidc.ProviderConfig, 0), + expectedProviderConfigs: nil, + }, + "single config not present in deployment oidcProviderConfigs": { + providerConfigs: []oidc.ProviderConfig{ + { + AuthNamePrefix: "provider-new", + Audience: "aud", + ClientId: util.MergoDelete, + IssuerUri: "https://provider-new.okta.com", + UserClaim: "sub", + GroupsClaim: "groups", + SupportsHumanFlows: false, + UseAuthorizationClaim: true, + }, + }, + expectedProviderConfigs: []map[string]any{ + { + "authNamePrefix": "provider-new", + "audience": "aud", + "issuerUri": "https://provider-new.okta.com", + "userClaim": "sub", + "groupsClaim": "groups", + "supportsHumanFlows": false, + "useAuthorizationClaim": true, + }, + }, + }, + "single config present in deployment oidcProviderConfig, without changes": { + providerConfigs: []oidc.ProviderConfig{ + { + AuthNamePrefix: "OIDC_WORKFORCE_USERID", + Audience: "aud", + IssuerUri: "https://provider.okta.com", + ClientId: "oktaClientId", + UserClaim: "sub", + GroupsClaim: util.MergoDelete, + RequestedScopes: []string{"openid"}, + SupportsHumanFlows: true, + UseAuthorizationClaim: false, + }, + }, + expectedProviderConfigs: []map[string]any{ + { + "authNamePrefix": "OIDC_WORKFORCE_USERID", + "audience": "aud", + "issuerUri": "https://provider.okta.com", + "clientId": "oktaClientId", + "requestedScopes": []string{ + "openid", + }, + "userClaim": "sub", + "supportsHumanFlows": true, + "useAuthorizationClaim": false, + "JWKSPollSecs": float64(360), + "additionalField": []any{ + "example.com", + }, + }, + }, + }, + "single config present in deployment oidcProviderConfig, but modified": { + providerConfigs: []oidc.ProviderConfig{ + { + AuthNamePrefix: "OIDC_WORKLOAD_USERID", + Audience: "aud", + ClientId: util.MergoDelete, + IssuerUri: "https://provider1.okta.com", + UserClaim: "sub", + GroupsClaim: "groups", + SupportsHumanFlows: false, + UseAuthorizationClaim: true, + }, + }, + expectedProviderConfigs: []map[string]any{ + { + "authNamePrefix": "OIDC_WORKLOAD_USERID", + "audience": "aud", + "issuerUri": "https://provider1.okta.com", + "userClaim": "sub", + "groupsClaim": "groups", + "supportsHumanFlows": false, + "useAuthorizationClaim": true, + "JWKSPollSecs": float64(360), + "additionalField": []interface{}{ + "example.com", + }, + }, + }, + }, + "multiple configs in deployment oidcProviderConfig": { + providerConfigs: []oidc.ProviderConfig{ + { + AuthNamePrefix: "OIDC_WORKFORCE_USERID", + Audience: "aud", + ClientId: "oktaClientId", + IssuerUri: "https://provider.okta.com", + UserClaim: "sub", + GroupsClaim: util.MergoDelete, + RequestedScopes: []string{"openid"}, + SupportsHumanFlows: true, + UseAuthorizationClaim: false, + }, + { + AuthNamePrefix: "OIDC_WORKLOAD_USERID", + Audience: "aud", + ClientId: util.MergoDelete, + IssuerUri: "https://provider.okta.com", + UserClaim: "sub", + GroupsClaim: util.MergoDelete, + RequestedScopes: []string{"openid"}, + SupportsHumanFlows: false, + UseAuthorizationClaim: false, + }, + { + AuthNamePrefix: "OIDC_WORKLOAD_GROUP_MEMBERSHIP", + Audience: "aud", + ClientId: util.MergoDelete, + IssuerUri: "https://provider.okta.com", + UserClaim: "sub", + GroupsClaim: "groups", + SupportsHumanFlows: false, + UseAuthorizationClaim: true, + }, + }, + expectedProviderConfigs: []map[string]any{ + { + "authNamePrefix": "OIDC_WORKFORCE_USERID", + "audience": "aud", + "issuerUri": "https://provider.okta.com", + "clientId": "oktaClientId", + "requestedScopes": []string{ + "openid", + }, + "userClaim": "sub", + "supportsHumanFlows": true, + "useAuthorizationClaim": false, + "JWKSPollSecs": float64(360), + "additionalField": []interface{}{ + "example.com", + }, + }, + { + "authNamePrefix": "OIDC_WORKLOAD_USERID", + "audience": "aud", + "issuerUri": "https://provider.okta.com", + "requestedScopes": []string{ + "openid", + }, + "userClaim": "sub", + "supportsHumanFlows": false, + "useAuthorizationClaim": false, + "JWKSPollSecs": float64(360), + "additionalField": []interface{}{ + "example.com", + }, + }, + { + "authNamePrefix": "OIDC_WORKLOAD_GROUP_MEMBERSHIP", + "audience": "aud", + "issuerUri": "https://provider.okta.com", + "userClaim": "sub", + "groupsClaim": "groups", + "supportsHumanFlows": false, + "useAuthorizationClaim": true, + "JWKSPollSecs": float64(360), + "additionalField": []interface{}{ + "example.com", + }, + }, + }, + }, + } + + for testName, testParameters := range tests { + t.Run(testName, func(t *testing.T) { + ac := getTestAutomationConfig() + ac.OIDCProviderConfigs = testParameters.providerConfigs + if err := ac.Apply(); err != nil { + t.Fatal(err) + } + + providerConfigs := ac.Deployment["oidcProviderConfigs"] + assert.Equal(t, testParameters.expectedProviderConfigs, providerConfigs) + }) + } +} + func TestApplyInto(t *testing.T) { config := AutomationConfig{ Auth: NewAuth(), diff --git a/controllers/om/testdata/automation_config.json b/controllers/om/testdata/automation_config.json index e2e9041e8..2ae929656 100644 --- a/controllers/om/testdata/automation_config.json +++ b/controllers/om/testdata/automation_config.json @@ -320,5 +320,51 @@ "clientCertificateMode": "OPTIONAL" }, "version": null, - "sharding": [] + "sharding": [], + "oidcProviderConfigs": [ + { + "audience": "aud", + "issuerUri": "https://provider.okta.com", + "clientId": "oktaClientId", + "requestedScopes": [ + "openid" + ], + "userClaim": "sub", + "JWKSPollSecs": 360, + "authNamePrefix": "OIDC_WORKFORCE_USERID", + "supportsHumanFlows": true, + "useAuthorizationClaim": false, + "additionalField": [ + "example.com" + ] + }, + { + "audience": "aud", + "issuerUri": "https://provider.okta.com", + "requestedScopes": [ + "openid" + ], + "userClaim": "sub", + "JWKSPollSecs": 360, + "authNamePrefix": "OIDC_WORKLOAD_USERID", + "supportsHumanFlows": false, + "useAuthorizationClaim": false, + "additionalField": [ + "example.com" + ] + }, + { + "audience": "aud", + "issuerUri": "https://provider.okta.com", + "userClaim": "sub", + "groupsClaim": "groups", + "JWKSPollSecs": 360, + "authNamePrefix": "OIDC_WORKLOAD_GROUP_MEMBERSHIP", + "supportsHumanFlows": false, + "useAuthorizationClaim": true, + "additionalField": [ + "example.com" + ] + } + ] } diff --git a/controllers/operator/authentication/authentication.go b/controllers/operator/authentication/authentication.go index 1ac47f9d1..1744317cd 100644 --- a/controllers/operator/authentication/authentication.go +++ b/controllers/operator/authentication/authentication.go @@ -7,6 +7,7 @@ import ( mdbv1 "github.com/mongodb/mongodb-kubernetes/api/v1/mdb" "github.com/mongodb/mongodb-kubernetes/controllers/om" "github.com/mongodb/mongodb-kubernetes/controllers/operator/ldap" + "github.com/mongodb/mongodb-kubernetes/controllers/operator/oidc" "github.com/mongodb/mongodb-kubernetes/pkg/util" ) @@ -16,6 +17,7 @@ type AuthResource interface { GetNamespace() string GetSecurity() *mdbv1.Security IsLDAPEnabled() bool + IsOIDCEnabled() bool GetLDAP(password, caContents string) *ldap.Ldap } @@ -52,6 +54,8 @@ type Options struct { // Only required if LDAP is configured as an authentication mechanism Ldap *ldap.Ldap + OIDCProviderConfigs []oidc.ProviderConfig + AutoUser string AutoPwd string diff --git a/controllers/operator/authentication/authentication_mechanism.go b/controllers/operator/authentication/authentication_mechanism.go index 679ea1c7f..90e6877e8 100644 --- a/controllers/operator/authentication/authentication_mechanism.go +++ b/controllers/operator/authentication/authentication_mechanism.go @@ -34,6 +34,7 @@ const ( ScramSha1 MechanismName = "SCRAM-SHA-1" MongoDBX509 MechanismName = "MONGODB-X509" LDAPPlain MechanismName = "PLAIN" + MongoDBOIDC MechanismName = "MONGODB-OIDC" // MongoDBCR is an umbrella term for SCRAM-SHA-1 and MONGODB-CR for legacy reasons, once MONGODB-CR // is enabled, users can auth with SCRAM-SHA-1 credentials @@ -65,7 +66,7 @@ func (m MechanismList) Contains(mechanismName MechanismName) bool { // supportedMechanisms returns a list of all supported authentication mechanisms // that can be configured by the Operator -var supportedMechanisms = []MechanismName{ScramSha256, MongoDBCR, MongoDBX509, LDAPPlain} +var supportedMechanisms = []MechanismName{ScramSha256, MongoDBCR, MongoDBX509, LDAPPlain, MongoDBOIDC} // mechanismsToDisable returns mechanisms which need to be disabled // based on the currently supported authentication mechanisms and the desiredMechanisms @@ -102,6 +103,8 @@ func convertToMechanismOrPanic(mechanismModeInCR string, ac *om.AutomationConfig return getMechanismByName(MongoDBCR) case util.SCRAMSHA256: return getMechanismByName(ScramSha256) + case util.OIDC: + return getMechanismByName(MongoDBOIDC) case util.SCRAM: // if we have already configured authentication, and it has been set to MONGODB-CR/SCRAM-SHA-1 // we can not transition. This needs to be done in the UI @@ -130,6 +133,8 @@ func getMechanismByName(name MechanismName) Mechanism { return &connectionX509{} case LDAPPlain: return &ldapAuthMechanism{} + case MongoDBOIDC: + return &oidcAuthMechanism{} } panic(xerrors.Errorf("unknown mechanism name %s", name)) diff --git a/controllers/operator/authentication/oidc.go b/controllers/operator/authentication/oidc.go new file mode 100644 index 000000000..bde78d4b5 --- /dev/null +++ b/controllers/operator/authentication/oidc.go @@ -0,0 +1,173 @@ +package authentication + +import ( + "fmt" + "slices" + "strings" + + "go.uber.org/zap" + "golang.org/x/xerrors" + + mdbv1 "github.com/mongodb/mongodb-kubernetes/api/v1/mdb" + "github.com/mongodb/mongodb-kubernetes/controllers/om" + "github.com/mongodb/mongodb-kubernetes/controllers/operator/oidc" + "github.com/mongodb/mongodb-kubernetes/pkg/util" + "github.com/mongodb/mongodb-kubernetes/pkg/util/stringutil" +) + +var MongoDBOIDCMechanism = &oidcAuthMechanism{} + +type oidcAuthMechanism struct{} + +func (o *oidcAuthMechanism) GetName() MechanismName { + return MongoDBOIDC +} + +func (o *oidcAuthMechanism) EnableAgentAuthentication(_ om.Connection, _ Options, _ *zap.SugaredLogger) error { + return xerrors.Errorf("OIDC agent authentication is not supported") +} + +func (o *oidcAuthMechanism) DisableAgentAuthentication(_ om.Connection, _ *zap.SugaredLogger) error { + return xerrors.Errorf("OIDC agent authentication is not supported") +} + +func (o *oidcAuthMechanism) EnableDeploymentAuthentication(conn om.Connection, opts Options, log *zap.SugaredLogger) error { + return conn.ReadUpdateAutomationConfig(func(ac *om.AutomationConfig) error { + if !stringutil.Contains(ac.Auth.DeploymentAuthMechanisms, string(MongoDBOIDC)) { + ac.Auth.DeploymentAuthMechanisms = append(ac.Auth.DeploymentAuthMechanisms, string(MongoDBOIDC)) + } + ac.OIDCProviderConfigs = opts.OIDCProviderConfigs + + return nil + }, log) +} + +func (o *oidcAuthMechanism) DisableDeploymentAuthentication(conn om.Connection, log *zap.SugaredLogger) error { + return conn.ReadUpdateAutomationConfig(func(ac *om.AutomationConfig) error { + ac.Auth.DeploymentAuthMechanisms = stringutil.Remove(ac.Auth.DeploymentAuthMechanisms, string(MongoDBOIDC)) + ac.OIDCProviderConfigs = nil + + return nil + }, log) +} + +func (o *oidcAuthMechanism) IsAgentAuthenticationConfigured(*om.AutomationConfig, Options) bool { + return false +} + +func (o *oidcAuthMechanism) IsDeploymentAuthenticationConfigured(ac *om.AutomationConfig, opts Options) bool { + return o.IsDeploymentAuthenticationEnabled(ac) && oidcProviderConfigsEqual(ac.OIDCProviderConfigs, opts.OIDCProviderConfigs) +} + +func (o *oidcAuthMechanism) IsDeploymentAuthenticationEnabled(ac *om.AutomationConfig) bool { + return stringutil.Contains(ac.Auth.DeploymentAuthMechanisms, string(MongoDBOIDC)) +} + +func oidcProviderConfigsEqual(lhs []oidc.ProviderConfig, rhs []oidc.ProviderConfig) bool { + if len(lhs) != len(rhs) { + return false + } + + lhsSorted := sortOIDCPProviderConfigs(lhs) + rhsSorted := sortOIDCPProviderConfigs(rhs) + + return slices.EqualFunc(lhsSorted, rhsSorted, oidcProviderConfigEqual) +} + +func sortOIDCPProviderConfigs(configs []oidc.ProviderConfig) []oidc.ProviderConfig { + configsSeq := slices.Values(configs) + return slices.SortedFunc(configsSeq, func(l, r oidc.ProviderConfig) int { + return strings.Compare(l.AuthNamePrefix, r.AuthNamePrefix) + }) +} + +func oidcProviderConfigEqual(l oidc.ProviderConfig, r oidc.ProviderConfig) bool { + if l.AuthNamePrefix != r.AuthNamePrefix { + return false + } + + if l.Audience != r.Audience { + return false + } + + if l.IssuerUri != r.IssuerUri { + return false + } + + if !slices.Equal(l.RequestedScopes, r.RequestedScopes) { + return false + } + + if l.UserClaim != r.UserClaim { + return false + } + + if l.GroupsClaim != r.GroupsClaim { + return false + } + + if l.SupportsHumanFlows != r.SupportsHumanFlows { + return false + } + + if l.UseAuthorizationClaim != r.UseAuthorizationClaim { + return false + } + + return true +} + +func MapOIDCProviderConfigs(oidcProviderConfigs []mdbv1.OIDCProviderConfig) []oidc.ProviderConfig { + if len(oidcProviderConfigs) == 0 { + return nil + } + + result := make([]oidc.ProviderConfig, len(oidcProviderConfigs)) + for i, providerConfig := range oidcProviderConfigs { + clientId := providerConfig.ClientId + if clientId == "" { + clientId = util.MergoDelete + } + + groupsClaim := providerConfig.GroupsClaim + if groupsClaim == "" { + groupsClaim = util.MergoDelete + } + + result[i] = oidc.ProviderConfig{ + AuthNamePrefix: providerConfig.ConfigurationName, + Audience: providerConfig.Audience, + IssuerUri: providerConfig.IssuerURI, + ClientId: clientId, + RequestedScopes: providerConfig.RequestedScopes, + UserClaim: providerConfig.UserClaim, + GroupsClaim: groupsClaim, + SupportsHumanFlows: mapToSupportHumanFlows(providerConfig.AuthorizationMethod), + UseAuthorizationClaim: mapToUseAuthorizationClaim(providerConfig.AuthorizationType), + } + } + + return result +} + +func mapToSupportHumanFlows(authMethod mdbv1.OIDCAuthorizationMethod) bool { + switch authMethod { + case mdbv1.OIDCAuthorizationMethodWorkforceIdentityFederation: + return true + case mdbv1.OIDCAuthorizationMethodWorkloadIdentityFederation: + return false + } + + panic(fmt.Sprintf("unsupported OIDC authorization method: %s", authMethod)) +} + +func mapToUseAuthorizationClaim(authType mdbv1.OIDCAuthorizationType) bool { + switch authType { + case mdbv1.OIDCAuthorizationTypeGroupMembership: + return true + case mdbv1.OIDCAuthorizationTypeUserID: + return false + } + + panic(fmt.Sprintf("unsupported OIDC authorization type: %s", authType)) +} diff --git a/controllers/operator/authentication/oidc_test.go b/controllers/operator/authentication/oidc_test.go new file mode 100644 index 000000000..bb22e1d23 --- /dev/null +++ b/controllers/operator/authentication/oidc_test.go @@ -0,0 +1,93 @@ +package authentication + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/zap" + + "github.com/mongodb/mongodb-kubernetes/controllers/om" + "github.com/mongodb/mongodb-kubernetes/controllers/operator/oidc" +) + +func TestOIDC_EnableDeploymentAuthentication(t *testing.T) { + conn := om.NewMockedOmConnection(om.NewDeployment()) + ac, err := conn.ReadAutomationConfig() + require.NoError(t, err) + assert.Empty(t, ac.OIDCProviderConfigs) + assert.Empty(t, ac.Auth.DeploymentAuthMechanisms) + + providerConfigs := []oidc.ProviderConfig{ + { + AuthNamePrefix: "okta", + Audience: "aud", + IssuerUri: "https://okta.mongodb.com", + ClientId: "client1", + RequestedScopes: []string{"openid", "profile"}, + UserClaim: "sub", + SupportsHumanFlows: true, + UseAuthorizationClaim: false, + }, + { + AuthNamePrefix: "congito", + Audience: "aud", + IssuerUri: "https://congito.mongodb.com", + ClientId: "client2", + UserClaim: "sub", + GroupsClaim: "groups", + SupportsHumanFlows: false, + UseAuthorizationClaim: true, + }, + } + + opts := Options{ + Mechanisms: []string{string(MongoDBOIDC)}, + OIDCProviderConfigs: providerConfigs, + } + + configured := MongoDBOIDCMechanism.IsDeploymentAuthenticationConfigured(ac, opts) + assert.False(t, configured) + + err = MongoDBOIDCMechanism.EnableDeploymentAuthentication(conn, opts, zap.S()) + require.NoError(t, err) + + ac, err = conn.ReadAutomationConfig() + require.NoError(t, err) + assert.Contains(t, ac.Auth.DeploymentAuthMechanisms, string(MongoDBOIDC)) + assert.Equal(t, providerConfigs, ac.OIDCProviderConfigs) + + configured = MongoDBOIDCMechanism.IsDeploymentAuthenticationConfigured(ac, opts) + assert.True(t, configured) + + err = MongoDBOIDCMechanism.DisableDeploymentAuthentication(conn, zap.S()) + require.NoError(t, err) + + ac, err = conn.ReadAutomationConfig() + require.NoError(t, err) + + configured = MongoDBOIDCMechanism.IsDeploymentAuthenticationConfigured(ac, opts) + assert.False(t, configured) + + assert.NotContains(t, ac.Auth.DeploymentAuthMechanisms, string(MongoDBOIDC)) + assert.Empty(t, ac.OIDCProviderConfigs) +} + +func TestOIDC_EnableAgentAuthentication(t *testing.T) { + conn := om.NewMockedOmConnection(om.NewDeployment()) + opts := Options{ + Mechanisms: []string{string(MongoDBOIDC)}, + } + + ac, err := conn.ReadAutomationConfig() + require.NoError(t, err) + + configured := MongoDBOIDCMechanism.IsAgentAuthenticationConfigured(ac, opts) + assert.False(t, configured) + + err = MongoDBOIDCMechanism.EnableAgentAuthentication(conn, opts, zap.S()) + require.Error(t, err) + + err = MongoDBOIDCMechanism.DisableAgentAuthentication(conn, zap.S()) + require.Error(t, err) +} diff --git a/controllers/operator/common_controller.go b/controllers/operator/common_controller.go index 436257e64..b4a289510 100644 --- a/controllers/operator/common_controller.go +++ b/controllers/operator/common_controller.go @@ -409,6 +409,10 @@ func (r *ReconcileCommonController) updateOmAuthentication(ctx context.Context, authOpts.Ldap = ar.GetLDAP(bindUserPassword, caContents) } + if ar.IsOIDCEnabled() { + authOpts.OIDCProviderConfigs = authentication.MapOIDCProviderConfigs(ar.GetSecurity().Authentication.OIDCProviderConfigs) + } + log.Debugf("Using authentication options %+v", authentication.Redact(authOpts)) agentSecretSelector := ar.GetSecurity().AgentClientCertificateSecretName(ar.GetName()) diff --git a/controllers/operator/mongodbuser_controller.go b/controllers/operator/mongodbuser_controller.go index 54e9de068..ac2f5cb2f 100644 --- a/controllers/operator/mongodbuser_controller.go +++ b/controllers/operator/mongodbuser_controller.go @@ -481,7 +481,11 @@ func waitForReadyState(conn om.Connection, log *zap.SugaredLogger) error { } func externalAuthMechanismsAvailable(mechanisms []string) bool { - return stringutil.ContainsAny(mechanisms, util.AutomationConfigLDAPOption, util.AutomationConfigX509Option) + return stringutil.ContainsAny(mechanisms, + util.AutomationConfigLDAPOption, + util.AutomationConfigX509Option, + util.AutomationConfigOIDCOption, + ) } func getAnnotationsForUserResource(user *userv1.MongoDBUser) (map[string]string, error) { diff --git a/controllers/operator/oidc/oidc_types.go b/controllers/operator/oidc/oidc_types.go new file mode 100644 index 000000000..c5c4ef80e --- /dev/null +++ b/controllers/operator/oidc/oidc_types.go @@ -0,0 +1,13 @@ +package oidc + +type ProviderConfig struct { + AuthNamePrefix string `json:"authNamePrefix"` + Audience string `json:"audience"` + IssuerUri string `json:"issuerUri"` + ClientId string `json:"clientId"` + RequestedScopes []string `json:"requestedScopes"` + UserClaim string `json:"userClaim"` + GroupsClaim string `json:"groupsClaim"` + SupportsHumanFlows bool `json:"supportsHumanFlows"` + UseAuthorizationClaim bool `json:"useAuthorizationClaim"` +} diff --git a/docker/mongodb-kubernetes-tests/tests/webhooks/e2e_mongodb_validation_webhook.py b/docker/mongodb-kubernetes-tests/tests/webhooks/e2e_mongodb_validation_webhook.py index 716ebe854..2420f4fe3 100644 --- a/docker/mongodb-kubernetes-tests/tests/webhooks/e2e_mongodb_validation_webhook.py +++ b/docker/mongodb-kubernetes-tests/tests/webhooks/e2e_mongodb_validation_webhook.py @@ -79,6 +79,71 @@ def test_replicaset_members_is_specified(self): exception_reason="'spec.members' must be specified if type of MongoDB is ReplicaSet", ) + def test_oidc_auth_with_mongodb_community(self): + resource = yaml.safe_load(open(yaml_fixture("invalid_oidc_mongodb_community.yaml"))) + self.create_custom_resource_from_object( + self.get_namespace(), + resource, + exception_reason="Cannot enable OIDC authentication with MongoDB Community Builds", + ) + + def test_oidc_auth_with_single_method(self): + resource = yaml.safe_load(open(yaml_fixture("invalid_oidc_single_auth.yaml"))) + self.create_custom_resource_from_object( + self.get_namespace(), + resource, + exception_reason="OIDC authentication cannot be used as the only authentication mechanism", + ) + + def test_oidc_auth_with_duplicate_config_name(self): + resource = yaml.safe_load(open(yaml_fixture("invalid_oidc_duplicate_config_name.yaml"))) + self.create_custom_resource_from_object( + self.get_namespace(), + resource, + exception_reason="OIDC provider config name OIDC-test is not unique", + ) + + def test_oidc_auth_with_multiple_workforce(self): + resource = yaml.safe_load(open(yaml_fixture("invalid_oidc_multiple_workforce.yaml"))) + self.create_custom_resource_from_object( + self.get_namespace(), + resource, + exception_reason="Only one OIDC provider config can be configured with Workforce Identity Federation. " + + "The following configs are configured with Workforce Identity Federation: OIDC-test, OIDC-test-2", + ) + + def test_oidc_auth_with_invalid_uri(self): + resource = yaml.safe_load(open(yaml_fixture("invalid_oidc_invalid_uri.yaml"))) + self.create_custom_resource_from_object( + self.get_namespace(), + resource, + exception_reason='Invalid IssuerURI in OIDC provider config \\"OIDC-test\\": invalid URL scheme (http or https): ', + ) + + def test_oidc_auth_with_invalid_clientid(self): + resource = yaml.safe_load(open(yaml_fixture("invalid_oidc_invalid_clientid.yaml"))) + self.create_custom_resource_from_object( + self.get_namespace(), + resource, + exception_reason='ClientId has to be specified in OIDC provider config \\"OIDC-test\\" with Workforce Identity Federation', + ) + + def test_oidc_auth_with_missing_groupclaim(self): + resource = yaml.safe_load(open(yaml_fixture("invalid_oidc_missing_groupclaim.yaml"))) + self.create_custom_resource_from_object( + self.get_namespace(), + resource, + exception_reason='GroupsClaim has to be specified in OIDC provider config \\"OIDC-test\\" when using Group Membership authorization', + ) + + def test_oidc_auth_with_no_providers(self): + resource = yaml.safe_load(open(yaml_fixture("invalid_oidc_no_providers.yaml"))) + self.create_custom_resource_from_object( + self.get_namespace(), + resource, + exception_reason="At least one OIDC provider config needs to be specified when OIDC authentication is enabled", + ) + def test_replicaset_members_is_specified_without_webhook(self): self._assert_validates_without_webhook( "mdbpolicy.mongodb.com", diff --git a/docker/mongodb-kubernetes-tests/tests/webhooks/fixtures/invalid_oidc_duplicate_config_name.yaml b/docker/mongodb-kubernetes-tests/tests/webhooks/fixtures/invalid_oidc_duplicate_config_name.yaml new file mode 100644 index 000000000..1e4710c23 --- /dev/null +++ b/docker/mongodb-kubernetes-tests/tests/webhooks/fixtures/invalid_oidc_duplicate_config_name.yaml @@ -0,0 +1,41 @@ +apiVersion: mongodb.com/v1 +kind: MongoDB +metadata: + name: oidc-replica-set +spec: + type: ReplicaSet + members: 3 + version: 8.0.5-ent + + opsManager: + configMapRef: + name: my-project + credentials: my-credentials + + security: + authentication: + agents: + mode: SCRAM + enabled: true + modes: + - SCRAM + - OIDC + oidcProviderConfigs: + - audience: "example-audience" + clientId: "example-client-id" + issuerURI: "https://example-issuer.com" + requestedScopes: [ ] + userClaim: "sub" + groupsClaim: "cognito:groups" + authorizationMethod: "WorkloadIdentityFederation" + authorizationType: "GroupMembership" + configurationName: "OIDC-test" + - audience: "example-audience-2" + clientId: "example-client-id-2" + issuerURI: "https://example-issuer-2.com" + requestedScopes: [ ] + userClaim: "sub" + groupsClaim: "cognito:groups" + authorizationMethod: "WorkloadIdentityFederation" + authorizationType: "GroupMembership" + configurationName: "OIDC-test" diff --git a/docker/mongodb-kubernetes-tests/tests/webhooks/fixtures/invalid_oidc_invalid_clientid.yaml b/docker/mongodb-kubernetes-tests/tests/webhooks/fixtures/invalid_oidc_invalid_clientid.yaml new file mode 100644 index 000000000..70a9d0599 --- /dev/null +++ b/docker/mongodb-kubernetes-tests/tests/webhooks/fixtures/invalid_oidc_invalid_clientid.yaml @@ -0,0 +1,31 @@ +apiVersion: mongodb.com/v1 +kind: MongoDB +metadata: + name: oidc-replica-set +spec: + type: ReplicaSet + members: 3 + version: 8.0.5-ent + + opsManager: + configMapRef: + name: my-project + credentials: my-credentials + + security: + authentication: + agents: + mode: SCRAM + enabled: true + modes: + - SCRAM + - OIDC + oidcProviderConfigs: + - audience: "example-audience" + issuerURI: "https://example-issuer.com" + requestedScopes: [ ] + userClaim: "sub" + groupsClaim: "cognito:groups" + authorizationMethod: "WorkforceIdentityFederation" + authorizationType: "GroupMembership" + configurationName: "OIDC-test" diff --git a/docker/mongodb-kubernetes-tests/tests/webhooks/fixtures/invalid_oidc_invalid_uri.yaml b/docker/mongodb-kubernetes-tests/tests/webhooks/fixtures/invalid_oidc_invalid_uri.yaml new file mode 100644 index 000000000..e5509790e --- /dev/null +++ b/docker/mongodb-kubernetes-tests/tests/webhooks/fixtures/invalid_oidc_invalid_uri.yaml @@ -0,0 +1,32 @@ +apiVersion: mongodb.com/v1 +kind: MongoDB +metadata: + name: oidc-replica-set +spec: + type: ReplicaSet + members: 3 + version: 8.0.5-ent + + opsManager: + configMapRef: + name: my-project + credentials: my-credentials + + security: + authentication: + agents: + mode: SCRAM + enabled: true + modes: + - SCRAM + - OIDC + oidcProviderConfigs: + - audience: "example-audience" + clientId: "example-client-id" + issuerURI: "hps://example-issuer.com" + requestedScopes: [ ] + userClaim: "sub" + groupsClaim: "cognito:groups" + authorizationMethod: "WorkloadIdentityFederation" + authorizationType: "GroupMembership" + configurationName: "OIDC-test" diff --git a/docker/mongodb-kubernetes-tests/tests/webhooks/fixtures/invalid_oidc_missing_groupclaim.yaml b/docker/mongodb-kubernetes-tests/tests/webhooks/fixtures/invalid_oidc_missing_groupclaim.yaml new file mode 100644 index 000000000..3b883b0bc --- /dev/null +++ b/docker/mongodb-kubernetes-tests/tests/webhooks/fixtures/invalid_oidc_missing_groupclaim.yaml @@ -0,0 +1,31 @@ +apiVersion: mongodb.com/v1 +kind: MongoDB +metadata: + name: oidc-replica-set +spec: + type: ReplicaSet + members: 3 + version: 8.0.5-ent + + opsManager: + configMapRef: + name: my-project + credentials: my-credentials + + security: + authentication: + agents: + mode: SCRAM + enabled: true + modes: + - SCRAM + - OIDC + oidcProviderConfigs: + - audience: "example-audience" + clientId: "example-client-id" + issuerURI: "https://example-issuer.com" + requestedScopes: [ ] + userClaim: "sub" + authorizationMethod: "WorkloadIdentityFederation" + authorizationType: "GroupMembership" + configurationName: "OIDC-test" diff --git a/docker/mongodb-kubernetes-tests/tests/webhooks/fixtures/invalid_oidc_mongodb_community.yaml b/docker/mongodb-kubernetes-tests/tests/webhooks/fixtures/invalid_oidc_mongodb_community.yaml new file mode 100644 index 000000000..340c810da --- /dev/null +++ b/docker/mongodb-kubernetes-tests/tests/webhooks/fixtures/invalid_oidc_mongodb_community.yaml @@ -0,0 +1,32 @@ +apiVersion: mongodb.com/v1 +kind: MongoDB +metadata: + name: oidc-replica-set +spec: + type: ReplicaSet + members: 3 + version: 8.0.5 + + opsManager: + configMapRef: + name: my-project + credentials: my-credentials + + security: + authentication: + agents: + mode: SCRAM + enabled: true + modes: + - SCRAM + - OIDC + oidcProviderConfigs: + - audience: "example-audience" + clientId: "example-client-id" + issuerURI: "https://example-issuer.com" + requestedScopes: [ ] + userClaim: "sub" + groupsClaim: "cognito:groups" + authorizationMethod: "WorkloadIdentityFederation" + authorizationType: "GroupMembership" + configurationName: "OIDC-test" diff --git a/docker/mongodb-kubernetes-tests/tests/webhooks/fixtures/invalid_oidc_multiple_workforce.yaml b/docker/mongodb-kubernetes-tests/tests/webhooks/fixtures/invalid_oidc_multiple_workforce.yaml new file mode 100644 index 000000000..b0a81bd69 --- /dev/null +++ b/docker/mongodb-kubernetes-tests/tests/webhooks/fixtures/invalid_oidc_multiple_workforce.yaml @@ -0,0 +1,41 @@ +apiVersion: mongodb.com/v1 +kind: MongoDB +metadata: + name: oidc-replica-set +spec: + type: ReplicaSet + members: 3 + version: 8.0.5-ent + + opsManager: + configMapRef: + name: my-project + credentials: my-credentials + + security: + authentication: + agents: + mode: SCRAM + enabled: true + modes: + - SCRAM + - OIDC + oidcProviderConfigs: + - audience: "example-audience" + clientId: "example-client-id" + issuerURI: "https://example-issuer.com" + requestedScopes: [ ] + userClaim: "sub" + groupsClaim: "cognito:groups" + authorizationMethod: "WorkforceIdentityFederation" + authorizationType: "GroupMembership" + configurationName: "OIDC-test" + - audience: "example-audience-2" + clientId: "example-client-id-2" + issuerURI: "https://example-issuer-2.com" + requestedScopes: [ ] + userClaim: "sub" + groupsClaim: "cognito:groups" + authorizationMethod: "WorkforceIdentityFederation" + authorizationType: "GroupMembership" + configurationName: "OIDC-test-2" diff --git a/docker/mongodb-kubernetes-tests/tests/webhooks/fixtures/invalid_oidc_no_providers.yaml b/docker/mongodb-kubernetes-tests/tests/webhooks/fixtures/invalid_oidc_no_providers.yaml new file mode 100644 index 000000000..5824c170b --- /dev/null +++ b/docker/mongodb-kubernetes-tests/tests/webhooks/fixtures/invalid_oidc_no_providers.yaml @@ -0,0 +1,22 @@ +apiVersion: mongodb.com/v1 +kind: MongoDB +metadata: + name: oidc-replica-set +spec: + type: ReplicaSet + members: 3 + version: 8.0.5-ent + + opsManager: + configMapRef: + name: my-project + credentials: my-credentials + + security: + authentication: + agents: + mode: SCRAM + enabled: true + modes: + - SCRAM + - OIDC diff --git a/docker/mongodb-kubernetes-tests/tests/webhooks/fixtures/invalid_oidc_single_auth.yaml b/docker/mongodb-kubernetes-tests/tests/webhooks/fixtures/invalid_oidc_single_auth.yaml new file mode 100644 index 000000000..025144bd4 --- /dev/null +++ b/docker/mongodb-kubernetes-tests/tests/webhooks/fixtures/invalid_oidc_single_auth.yaml @@ -0,0 +1,29 @@ +apiVersion: mongodb.com/v1 +kind: MongoDB +metadata: + name: oidc-replica-set +spec: + type: ReplicaSet + members: 3 + version: 8.0.5-ent + + opsManager: + configMapRef: + name: my-project + credentials: my-credentials + + security: + authentication: + enabled: true + modes: + - OIDC + oidcProviderConfigs: + - audience: "example-audience" + clientId: "example-client-id" + issuerURI: "https://example-issuer.com" + requestedScopes: [ ] + userClaim: "sub" + groupsClaim: "cognito:groups" + authorizationMethod: "WorkloadIdentityFederation" + authorizationType: "GroupMembership" + configurationName: "OIDC-test" diff --git a/go.mod b/go.mod index 45c8cf406..7d2052c92 100644 --- a/go.mod +++ b/go.mod @@ -13,6 +13,8 @@ require ( github.com/hashicorp/go-retryablehttp v0.7.7 github.com/hashicorp/vault/api v1.16.0 github.com/imdario/mergo v0.3.15 + github.com/onsi/ginkgo/v2 v2.17.1 + github.com/onsi/gomega v1.32.0 github.com/pkg/errors v0.9.1 github.com/prometheus/client_golang v1.22.0 github.com/r3labs/diff/v3 v3.0.1 diff --git a/pkg/util/constants.go b/pkg/util/constants.go index d44b86223..b2082f6ee 100644 --- a/pkg/util/constants.go +++ b/pkg/util/constants.go @@ -140,6 +140,7 @@ const ( AutomationConfigLDAPOption = "PLAIN" AutomationConfigScramSha256Option = "SCRAM-SHA-256" AutomationConfigScramSha1Option = "MONGODB-CR" + AutomationConfigOIDCOption = "MONGODB-OIDC" AutomationAgentUserName = "mms-automation-agent" RequireClientCertificates = "REQUIRE" OptionalClientCertficates = "OPTIONAL"