feat: add configurable mTLS identity extraction with fallback chain (#3640)

Add support for configurable identity attributes in mTLS authentication,
allowing identity extraction from CommonName, Subject DN, Email SAN,
URI SAN, or DNSName SAN with fallback chain support. Includes regex
pattern matching for URI SANs (e.g., SPIFFE workload IDs).

- Add MTLSConfig with identity attributes, URISANPattern, and index fields
- Implement extractMTLSIdentity with fallback chain logic
- Move the mtls tests in the api package to pkg/api/mtls_test.go

Signed-off-by: Andrei Aaron <andreifdaaron@gmail.com>
This commit is contained in:
Andrei Aaron
2025-12-18 19:10:47 +02:00
committed by GitHub
parent f2064c9af0
commit 79439bbf63
14 changed files with 2788 additions and 1435 deletions
+39
View File
@@ -70,6 +70,34 @@ type TLSConfig struct {
CACert string
}
type MTLSConfig struct {
// IdentityAttibutes is an ordered list of identity attributes to try
// Options: "CommonName", "Subject", "Email", "URI", "DNSName" (case-insensitive)
// Default: ["CommonName"] (backward compatible)
IdentityAttibutes []string `json:"identityAttributes,omitempty" mapstructure:"identityAttributes,omitempty"`
// URISANPattern is a regex pattern to extract identity from URI SAN
// Only used when IdentityAttibutes contains "URI"
// Example: "spiffe://example.org/workload/(.*)" extracts the workload ID
// If empty, uses the full URI SAN value
URISANPattern string `json:"uriSanPattern,omitempty" mapstructure:"uriSanPattern,omitempty"`
// URISANIndex specifies which URI SAN to use if multiple exist (0-based)
// Maps to cert.URIs[index] - the URIs field is a slice, so index is needed
// Default: 0 (first URI)
URISANIndex int `json:"uriSanIndex,omitempty" mapstructure:"uriSanIndex,omitempty"`
// DNSANIndex specifies which DNS SAN to use if multiple exist (0-based)
// Maps to cert.DNSNames[index] - the DNSNames field is a slice, so index is needed
// Default: 0 (first DNS name)
DNSANIndex int `json:"dnsSanIndex,omitempty" mapstructure:"dnsSanIndex,omitempty"`
// EmailSANIndex specifies which Email SAN to use if multiple exist (0-based)
// Maps to cert.EmailAddresses[index] - the EmailAddresses field is a slice, so index is needed
// Default: 0 (first email)
EmailSANIndex int `json:"emailSanIndex,omitempty" mapstructure:"emailSanIndex,omitempty"`
}
type AuthHTPasswd struct {
Path string
}
@@ -86,6 +114,7 @@ type AuthConfig struct {
SessionEncryptKey []byte `json:"-"`
SessionDriver map[string]any `mapstructure:",omitempty"`
SecureSession *bool `json:"secureSession,omitempty" mapstructure:"secureSession,omitempty"`
MTLS *MTLSConfig `json:"mtls,omitempty" mapstructure:"mtls,omitempty"`
}
// IsLdapAuthEnabled checks if LDAP authentication is enabled in this auth config.
@@ -141,6 +170,15 @@ func (a *AuthConfig) GetFailDelay() int {
return a.FailDelay
}
// GetMTLSConfig returns the mTLS configuration if it exists.
func (a *AuthConfig) GetMTLSConfig() *MTLSConfig {
if a == nil {
return nil
}
return a.MTLS
}
type BearerConfig struct {
Realm string
Service string
@@ -638,6 +676,7 @@ func (c *Config) UpdateReloadableConfig(newConfig *Config) {
c.HTTP.Auth.APIKey = newConfig.HTTP.Auth.APIKey
c.HTTP.Auth.OpenID = newConfig.HTTP.Auth.OpenID
c.HTTP.Auth.SecureSession = newConfig.HTTP.Auth.SecureSession
c.HTTP.Auth.MTLS = newConfig.HTTP.Auth.MTLS
}
// Initialize and update AccessControlConfig
+138
View File
@@ -873,6 +873,31 @@ func TestConfig(t *testing.T) {
}
So(authConfig.GetFailDelay(), ShouldEqual, 5)
})
Convey("Test GetMTLSConfig()", func() {
// Test with nil AuthConfig
var authConfig *config.AuthConfig = nil
So(authConfig.GetMTLSConfig(), ShouldBeNil)
// Test with AuthConfig but nil MTLS
authConfig = &config.AuthConfig{}
So(authConfig.GetMTLSConfig(), ShouldBeNil)
// Test with AuthConfig and MTLS configured
authConfig = &config.AuthConfig{
MTLS: &config.MTLSConfig{
IdentityAttibutes: []string{"CommonName", "URI"},
URISANPattern: "spiffe://example.org/workload/(.*)",
},
}
mtlsConfig := authConfig.GetMTLSConfig()
So(mtlsConfig, ShouldNotBeNil)
So(len(mtlsConfig.IdentityAttibutes), ShouldEqual, 2)
So(mtlsConfig.IdentityAttibutes[0], ShouldEqual, "CommonName")
So(mtlsConfig.IdentityAttibutes[1], ShouldEqual, "URI")
So(mtlsConfig.URISANPattern, ShouldEqual, "spiffe://example.org/workload/(.*)")
})
})
Convey("Test LDAPConfig methods", t, func() {
@@ -1036,6 +1061,10 @@ func TestConfig(t *testing.T) {
"type": "redis",
"host": "localhost",
},
MTLS: &config.MTLSConfig{
IdentityAttibutes: []string{"CommonName"},
URISANPattern: "spiffe://example.org/workload/(.*)",
},
},
},
}
@@ -1049,6 +1078,8 @@ func TestConfig(t *testing.T) {
So(authConfig.IsBearerAuthEnabled(), ShouldBeTrue)
So(authConfig.IsOpenIDAuthEnabled(), ShouldBeTrue)
So(authConfig.IsAPIKeyEnabled(), ShouldBeFalse)
So(authConfig.GetMTLSConfig(), ShouldNotBeNil)
So(authConfig.GetMTLSConfig().IdentityAttibutes[0], ShouldEqual, "CommonName")
// Test deep copy isolation by modifying nested structures
authConfig.LDAP.Address = "modified-ldap.example.com"
@@ -1056,6 +1087,8 @@ func TestConfig(t *testing.T) {
authConfig.OpenID.Providers["google"].Scopes[0] = "modified-scope"
authConfig.SessionHashKey[0] = 'M'
authConfig.SessionDriver["type"] = "modified-driver"
authConfig.MTLS.IdentityAttibutes[0] = "URI"
authConfig.MTLS.URISANPattern = "modified-pattern"
// Verify original is unchanged
So(cfg.HTTP.Auth.LDAP.Address, ShouldEqual, "ldap.example.com")
@@ -1063,6 +1096,8 @@ func TestConfig(t *testing.T) {
So(cfg.HTTP.Auth.OpenID.Providers["google"].Scopes[0], ShouldEqual, "openid")
So(cfg.HTTP.Auth.SessionHashKey[0], ShouldEqual, byte('h'))
So(cfg.HTTP.Auth.SessionDriver["type"], ShouldEqual, "redis")
So(cfg.HTTP.Auth.MTLS.IdentityAttibutes[0], ShouldEqual, "CommonName")
So(cfg.HTTP.Auth.MTLS.URISANPattern, ShouldEqual, "spiffe://example.org/workload/(.*)")
})
Convey("Test that returned AuthConfig is isolated when config is updated via UpdateReloadableConfig", func() {
@@ -2852,6 +2887,109 @@ func TestConfig(t *testing.T) {
})
})
Convey("Test UpdateReloadableConfig MTLS config updates", t, func() {
Convey("Test MTLS config is updated in UpdateReloadableConfig", func() {
// Create initial config with MTLS
cfg := &config.Config{
HTTP: config.HTTPConfig{
Auth: &config.AuthConfig{
MTLS: &config.MTLSConfig{
IdentityAttibutes: []string{"CommonName"},
URISANPattern: "spiffe://old.example.org/workload/(.*)",
URISANIndex: 0,
},
},
},
}
// Create new config with updated MTLS
newConfig := &config.Config{
HTTP: config.HTTPConfig{
Auth: &config.AuthConfig{
MTLS: &config.MTLSConfig{
IdentityAttibutes: []string{"URI", "CommonName"},
URISANPattern: "spiffe://new.example.org/workload/(.*)",
URISANIndex: 1,
DNSANIndex: 2,
},
},
},
}
// Update the config
cfg.UpdateReloadableConfig(newConfig)
// Verify MTLS config was updated
So(cfg.HTTP.Auth.MTLS, ShouldNotBeNil)
So(len(cfg.HTTP.Auth.MTLS.IdentityAttibutes), ShouldEqual, 2)
So(cfg.HTTP.Auth.MTLS.IdentityAttibutes[0], ShouldEqual, "URI")
So(cfg.HTTP.Auth.MTLS.IdentityAttibutes[1], ShouldEqual, "CommonName")
So(cfg.HTTP.Auth.MTLS.URISANPattern, ShouldEqual, "spiffe://new.example.org/workload/(.*)")
So(cfg.HTTP.Auth.MTLS.URISANIndex, ShouldEqual, 1)
So(cfg.HTTP.Auth.MTLS.DNSANIndex, ShouldEqual, 2)
})
Convey("Test MTLS config is set to nil when new config has nil MTLS", func() {
// Create initial config with MTLS
cfg := &config.Config{
HTTP: config.HTTPConfig{
Auth: &config.AuthConfig{
MTLS: &config.MTLSConfig{
IdentityAttibutes: []string{"CommonName"},
},
},
},
}
// Create new config with nil MTLS
newConfig := &config.Config{
HTTP: config.HTTPConfig{
Auth: &config.AuthConfig{
MTLS: nil,
},
},
}
// Update the config
cfg.UpdateReloadableConfig(newConfig)
// Verify MTLS config was set to nil
So(cfg.HTTP.Auth.MTLS, ShouldBeNil)
})
Convey("Test MTLS config is created when going from nil to non-nil", func() {
// Create initial config with nil MTLS
cfg := &config.Config{
HTTP: config.HTTPConfig{
Auth: &config.AuthConfig{
MTLS: nil,
},
},
}
// Create new config with MTLS
newConfig := &config.Config{
HTTP: config.HTTPConfig{
Auth: &config.AuthConfig{
MTLS: &config.MTLSConfig{
IdentityAttibutes: []string{"URI"},
URISANPattern: "spiffe://new.example.org/workload/(.*)",
},
},
},
}
// Update the config
cfg.UpdateReloadableConfig(newConfig)
// Verify MTLS config was created
So(cfg.HTTP.Auth.MTLS, ShouldNotBeNil)
So(len(cfg.HTTP.Auth.MTLS.IdentityAttibutes), ShouldEqual, 1)
So(cfg.HTTP.Auth.MTLS.IdentityAttibutes[0], ShouldEqual, "URI")
So(cfg.HTTP.Auth.MTLS.URISANPattern, ShouldEqual, "spiffe://new.example.org/workload/(.*)")
})
})
Convey("Test UpdateReloadableConfig Storage.SubPaths logic", t, func() {
Convey("Test existing SubPaths are updated", func() {
// Create initial config with SubPaths