mirror of
https://github.com/hashicorp/vault.git
synced 2026-05-04 20:06:27 +02:00
* adding root rotation for ldap auth method for schema AD * adding test cases for root rotation * code fix and adding TestRotateRoot_EncodeUTF16LEBytes * adding constants * schema validation + unit test * updated unit test * removed duplicate enum * adding acceptance test, unit test, changelog and updating schemaType to schema * adding logs and comments for debugging * added validation for config params * adding validation and test cases to enforce encrypted connection requirements for AD password rotation * adding fix to data race error in CI pipeline * addressing PR comments * fix for backward compatibility for schema and test * adding validation and tests for multiple URLs for AD root rotation --------- Co-authored-by: Stuti Srivastava <stuti.srivastava@hashicorp.com> Co-authored-by: Prajna Nayak <prajna.nayak@hashicorp.com>
This commit is contained in:
parent
72d40c1343
commit
f43fdf54ab
@ -1543,6 +1543,7 @@ func TestLdapAuthBackend_ConfigUpgrade(t *testing.T) {
|
||||
UsernameAsAlias: false,
|
||||
DerefAliases: "never",
|
||||
MaximumPageSize: 1000,
|
||||
Schema: ldaputil.SchemaOpenLDAP, // Default schema when not specified
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@ -128,6 +128,12 @@ func (b *backend) Config(ctx context.Context, req *logical.Request) (*ldapConfig
|
||||
persistNeeded = true
|
||||
}
|
||||
|
||||
// Upgrade path: Set default schema for configs created before schema field was added
|
||||
if result.Schema == "" {
|
||||
result.Schema = ldaputil.SchemaOpenLDAP
|
||||
persistNeeded = true
|
||||
}
|
||||
|
||||
if persistNeeded && (b.System().LocalMount() || !b.System().ReplicationState().HasState(consts.ReplicationPerformanceSecondary|consts.ReplicationPerformanceStandby)) {
|
||||
entry, err := logical.StorageEntryJSON("config", result)
|
||||
if err != nil {
|
||||
|
||||
@ -5,7 +5,11 @@ package ldap
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"unicode/utf16"
|
||||
|
||||
"github.com/go-ldap/ldap/v3"
|
||||
"github.com/hashicorp/vault/sdk/framework"
|
||||
@ -83,6 +87,19 @@ func (b *backend) rotateRootCredential(ctx context.Context, req *logical.Request
|
||||
return responseError{errors.New("auth is not using authenticated search, no root to rotate")}
|
||||
}
|
||||
|
||||
// Validate TLS requirements for AD password rotation
|
||||
schema := ldaputil.NormalizedSchema(cfg.Schema)
|
||||
if schema == ldaputil.SchemaAD {
|
||||
// Validate URL(s) which will actually be used for rotation
|
||||
urlToValidate := cfg.Url
|
||||
if cfg.RotationUrl != "" {
|
||||
urlToValidate = cfg.RotationUrl
|
||||
}
|
||||
if err := validateADRotationURLs(urlToValidate, cfg.StartTLS); err != nil {
|
||||
return responseError{err}
|
||||
}
|
||||
}
|
||||
|
||||
// grab our ldap client
|
||||
client := ldaputil.Client{
|
||||
Logger: b.Logger(),
|
||||
@ -98,16 +115,14 @@ func (b *backend) rotateRootCredential(ctx context.Context, req *logical.Request
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Close the connection when done to avoid leaking connections, especially during repeated rotation attempts.defer conn.Close()
|
||||
defer conn.Close()
|
||||
|
||||
err = conn.Bind(u, p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
lreq := &ldap.ModifyRequest{
|
||||
DN: cfg.BindDN,
|
||||
}
|
||||
|
||||
var newPassword string
|
||||
if cfg.PasswordPolicy != "" {
|
||||
newPassword, err = b.System().GeneratePasswordFromPolicy(ctx, cfg.PasswordPolicy)
|
||||
@ -118,12 +133,38 @@ func (b *backend) rotateRootCredential(ctx context.Context, req *logical.Request
|
||||
return err
|
||||
}
|
||||
|
||||
lreq.Replace("userPassword", []string{newPassword})
|
||||
switch schema {
|
||||
case ldaputil.SchemaAD:
|
||||
// AD root rotation requires:
|
||||
// 1) Quoted password
|
||||
// 2) UTF-16LE encoding
|
||||
// 3) Encrypted connection (LDAPS/StartTLS)
|
||||
// Without these, AD rejects password updates.
|
||||
b.Logger().Debug("rotating root password using AD schema")
|
||||
quotedPwd := fmt.Sprintf("\"%s\"", newPassword)
|
||||
utf16Bytes := encodeUTF16LEBytes(quotedPwd)
|
||||
|
||||
err = conn.Modify(lreq)
|
||||
if err != nil {
|
||||
return err
|
||||
modReq := ldap.NewModifyRequest(cfg.BindDN, nil)
|
||||
modReq.Replace("unicodePwd", []string{string(utf16Bytes)})
|
||||
|
||||
if err := conn.Modify(modReq); err != nil {
|
||||
return fmt.Errorf("failed to modify AD password for %q: %w", cfg.BindDN, err)
|
||||
}
|
||||
|
||||
case ldaputil.SchemaOpenLDAP:
|
||||
b.Logger().Debug("rotating root password using openldap schema")
|
||||
lreq := &ldap.ModifyRequest{
|
||||
DN: cfg.BindDN,
|
||||
}
|
||||
lreq.Replace("userPassword", []string{newPassword})
|
||||
|
||||
if err := conn.Modify(lreq); err != nil {
|
||||
return fmt.Errorf("failed to modify OpenLDAP password: %w", err)
|
||||
}
|
||||
default:
|
||||
return responseError{fmt.Errorf("unsupported schema type for password rotation: %s", schema)}
|
||||
}
|
||||
|
||||
// update config with new password
|
||||
cfg.BindPassword = newPassword
|
||||
entry, err := logical.StorageEntryJSON("config", cfg)
|
||||
@ -138,6 +179,56 @@ func (b *backend) rotateRootCredential(ctx context.Context, req *logical.Request
|
||||
return nil
|
||||
}
|
||||
|
||||
// encodeUTF16LEBytes encodes a string as UTF-16LE bytes for AD password changes.
|
||||
// This encoding is required for Active Directory password changes via the unicodePwd attribute.
|
||||
func encodeUTF16LEBytes(s string) []byte {
|
||||
utf16Runes := utf16.Encode([]rune(s))
|
||||
buf := make([]byte, len(utf16Runes)*2)
|
||||
for i, r := range utf16Runes {
|
||||
binary.LittleEndian.PutUint16(buf[i*2:], r)
|
||||
}
|
||||
return buf
|
||||
}
|
||||
|
||||
// validateADRotationURLs validates that all URLs in the provided URL string
|
||||
// meet the security requirements for AD password rotation.
|
||||
func validateADRotationURLs(urlString string, startTLS bool) error {
|
||||
// AD password rotation requires encrypted connections (LDAPS or StartTLS)
|
||||
// Supported configurations:
|
||||
// 1. ldaps:// with proper certificate validation (recommended)
|
||||
// 2. ldaps:// with insecure_tls=true (skips certificate validation)
|
||||
// 3. ldap:// with starttls=true (with or without explicit certificates)
|
||||
if strings.TrimSpace(urlString) == "" {
|
||||
return errors.New("AD password rotation requires a configured URL")
|
||||
}
|
||||
// Split on commas to handle multiple URLs
|
||||
rawURLs := strings.Split(urlString, ",")
|
||||
hasNonTLSURL := false
|
||||
for _, rawURL := range rawURLs {
|
||||
rawURL := strings.TrimSpace(rawURL)
|
||||
if rawURL == "" {
|
||||
continue
|
||||
}
|
||||
urlLower := strings.ToLower(rawURL)
|
||||
isLDAPS := strings.HasPrefix(urlLower, "ldaps://")
|
||||
isLDAP := strings.HasPrefix(urlLower, "ldap://")
|
||||
|
||||
// Validate that URL uses a supported protocol
|
||||
if !isLDAPS && !isLDAP {
|
||||
return fmt.Errorf("AD password rotation requires ldap:// or ldaps:// protocol, got: %s", rawURL)
|
||||
}
|
||||
// Track if any URL uses non-TLS ldap://
|
||||
if isLDAP {
|
||||
hasNonTLSURL = true
|
||||
}
|
||||
}
|
||||
// If any URL uses ldap:// (non-TLS), require StartTLS to ensure encryption
|
||||
if hasNonTLSURL && !startTLS {
|
||||
return errors.New("AD password rotation with ldap:// requires starttls=true for encrypted connection")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
const pathConfigRotateRootHelpSyn = `
|
||||
Request to rotate the LDAP credentials used by Vault
|
||||
`
|
||||
|
||||
@ -4,18 +4,24 @@
|
||||
package ldap
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"unicode/utf16"
|
||||
|
||||
"github.com/hashicorp/vault/helper/testhelpers/ldap"
|
||||
logicaltest "github.com/hashicorp/vault/helper/testhelpers/logical"
|
||||
"github.com/hashicorp/vault/sdk/helper/ldaputil"
|
||||
"github.com/hashicorp/vault/sdk/logical"
|
||||
)
|
||||
|
||||
// TestRotateRoot_DefaultSchema tests that the default schema type is used when not explicitly set and that rotation works in that case.
|
||||
// This test relies on a docker ldap server with a suitable person object (cn=admin,dc=planetexpress,dc=com)
|
||||
// with bindpassword "admin". `PrepareTestContainer` does this for us. - see the backend_test for more details
|
||||
func TestRotateRoot(t *testing.T) {
|
||||
// with bindpassword "admin". `PrepareTestContainer` does this for us - see the backend_test for more details.
|
||||
func TestRotateRoot_DefaultSchema(t *testing.T) {
|
||||
if os.Getenv(logicaltest.TestEnvVar) == "" {
|
||||
t.Skip("skipping rotate root tests because VAULT_ACC is unset")
|
||||
}
|
||||
@ -57,6 +63,12 @@ func TestRotateRoot(t *testing.T) {
|
||||
}
|
||||
|
||||
newCFG, err := b.Config(ctx, req)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to get config after rotation: %s", err)
|
||||
}
|
||||
if newCFG == nil {
|
||||
t.Fatal("config is nil after rotation")
|
||||
}
|
||||
if newCFG.BindDN != cfg.BindDN {
|
||||
t.Fatalf("a value in config that should have stayed the same changed: %s", cfg.BindDN)
|
||||
}
|
||||
@ -113,6 +125,12 @@ func TestRotateRootWithRotationUrl(t *testing.T) {
|
||||
}
|
||||
|
||||
newCFG, err := b.Config(ctx, req)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to get config after rotation: %s", err)
|
||||
}
|
||||
if newCFG == nil {
|
||||
t.Fatal("config is nil after rotation")
|
||||
}
|
||||
if newCFG.BindDN != cfg.BindDN {
|
||||
t.Fatalf("BindDN %q changed unexpectedly, found new value %q", cfg.BindDN, newCFG.BindDN)
|
||||
}
|
||||
@ -124,3 +142,370 @@ func TestRotateRootWithRotationUrl(t *testing.T) {
|
||||
t.Fatalf("URL %q changed unexpectedly, found new value %q", mainDummyUrl, newCFG.Url)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRotateRoot_Schema_OpenLDAP tests that rotation for OpenLDAP schema
|
||||
func TestRotateRoot_Schema_OpenLDAP(t *testing.T) {
|
||||
if os.Getenv(logicaltest.TestEnvVar) == "" {
|
||||
t.Skip("skipping rotate root tests because VAULT_ACC is unset")
|
||||
}
|
||||
ctx := context.Background()
|
||||
b, store := createBackendWithStorage(t)
|
||||
cleanup, cfg := ldap.PrepareTestContainer(t, ldap.DefaultVersion)
|
||||
defer cleanup()
|
||||
// set up auth config
|
||||
req := &logical.Request{
|
||||
Operation: logical.UpdateOperation,
|
||||
Path: "config",
|
||||
Storage: store,
|
||||
Data: map[string]interface{}{
|
||||
"url": cfg.Url,
|
||||
"binddn": cfg.BindDN,
|
||||
"bindpass": cfg.BindPassword,
|
||||
"userdn": cfg.UserDN,
|
||||
"schema": ldaputil.SchemaOpenLDAP,
|
||||
},
|
||||
}
|
||||
resp, err := b.HandleRequest(ctx, req)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to initialize ldap auth config: %s", err)
|
||||
}
|
||||
if resp != nil && resp.IsError() {
|
||||
t.Fatalf("failed to initialize ldap auth config: %s", resp.Data["error"])
|
||||
}
|
||||
req = &logical.Request{
|
||||
Operation: logical.UpdateOperation,
|
||||
Path: "config/rotate-root",
|
||||
Storage: store,
|
||||
}
|
||||
_, err = b.HandleRequest(ctx, req)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to rotate password: %s", err)
|
||||
}
|
||||
newCFG, err := b.Config(ctx, req)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to get config after rotation: %s", err)
|
||||
}
|
||||
if newCFG == nil {
|
||||
t.Fatal("config is nil after rotation")
|
||||
}
|
||||
if newCFG.BindDN != cfg.BindDN {
|
||||
t.Fatalf("a value in config that should have stayed the same changed: %s", cfg.BindDN)
|
||||
}
|
||||
if newCFG.BindPassword == cfg.BindPassword {
|
||||
t.Fatalf("the password should have changed, but it didn't")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRotateRoot_UnsupportedSchema tests unsupported schema handling
|
||||
func TestRotateRoot_UnsupportedSchema(t *testing.T) {
|
||||
if os.Getenv(logicaltest.TestEnvVar) == "" {
|
||||
t.Skip("skipping rotate root tests because VAULT_ACC is unset")
|
||||
}
|
||||
ctx := context.Background()
|
||||
b, store := createBackendWithStorage(t)
|
||||
cleanup, cfg := ldap.PrepareTestContainer(t, ldap.DefaultVersion)
|
||||
defer cleanup()
|
||||
// set up auth config
|
||||
req := &logical.Request{
|
||||
Operation: logical.UpdateOperation,
|
||||
Path: "config",
|
||||
Storage: store,
|
||||
Data: map[string]interface{}{
|
||||
"url": cfg.Url,
|
||||
"binddn": cfg.BindDN,
|
||||
"bindpass": cfg.BindPassword,
|
||||
"userdn": cfg.UserDN,
|
||||
"schema": "unsupported_schema", // unsupported schema type
|
||||
},
|
||||
}
|
||||
|
||||
resp, err := b.HandleRequest(ctx, req)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to initialize ldap auth config: %s", err)
|
||||
}
|
||||
// Config creation should fail with logical error
|
||||
if resp == nil || !resp.IsError() {
|
||||
t.Fatal("expected config creation to fail with unsupported schema type, but it succeeded")
|
||||
}
|
||||
// Verify error message contains expected text
|
||||
errMsg := resp.Error().Error()
|
||||
if !strings.Contains(errMsg, "unsupported schema type") {
|
||||
t.Fatalf("expected error containing 'unsupported schema type', got: %s", errMsg)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRotateRoot_EncodeUTF16LEBytes tests the encoding of UTF-16LE bytes for AD password modification.
|
||||
func TestRotateRoot_EncodeUTF16LEBytes(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
}{
|
||||
{name: "empty_string", input: ""},
|
||||
{name: "single_char", input: "A"},
|
||||
{name: "quoted_password", input: "\"password\""},
|
||||
{name: "alphanumeric_with_special", input: "Pass123!"},
|
||||
{name: "unicode_chars", input: "Pāsswörd✓"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := encodeUTF16LEBytes(tt.input)
|
||||
r := utf16.Encode([]rune(tt.input))
|
||||
expected := make([]byte, len(r)*2)
|
||||
for i, v := range r {
|
||||
binary.LittleEndian.PutUint16(expected[i*2:], v)
|
||||
}
|
||||
|
||||
if !bytes.Equal(got, expected) {
|
||||
t.Fatalf("encodeUTF16LEBytes(%q) = %v, want %v", tt.input, got, expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// adTLSTestCase defines a test case for AD TLS validation
|
||||
type adTLSTestCase struct {
|
||||
name string
|
||||
url string
|
||||
insecureTLS bool
|
||||
startTLS bool
|
||||
certificate string
|
||||
passwordPolicy string
|
||||
expectError bool
|
||||
errorContains string
|
||||
}
|
||||
|
||||
// TestRotateRoot_AD_TLS_Validation tests TLS validation for AD schema
|
||||
func TestRotateRoot_AD_TLS_Validation(t *testing.T) {
|
||||
tests := []adTLSTestCase{
|
||||
// Valid: ldaps - TLS validation passes, connection refused immediately (no real server).
|
||||
{
|
||||
name: "ldaps_valid",
|
||||
url: "ldaps://127.0.0.1:1",
|
||||
insecureTLS: true,
|
||||
},
|
||||
// Valid: ldap + starttls - with optional cert and password policy
|
||||
{
|
||||
name: "ldap_with_starttls_valid",
|
||||
url: "ldap://127.0.0.1:1",
|
||||
startTLS: true,
|
||||
certificate: testCert,
|
||||
passwordPolicy: "test-policy",
|
||||
},
|
||||
// Invalid: ldap without starttls - must surface as LogicalErrorResponse
|
||||
{
|
||||
name: "ldap_without_starttls_invalid",
|
||||
url: "ldap://example.com",
|
||||
expectError: true,
|
||||
errorContains: "AD password rotation with ldap:// requires starttls=true for encrypted connection",
|
||||
},
|
||||
// Invalid: unsupported protocol - must surface as LogicalErrorResponse
|
||||
{
|
||||
name: "invalid_protocol",
|
||||
url: "http://example.com",
|
||||
expectError: true,
|
||||
errorContains: "AD password rotation requires ldap:// or ldaps:// protocol, got: http://example.com",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
testADTLSValidation(t, tt)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Valid self-signed certificate for testing (shared across tests)
|
||||
const testCert = `-----BEGIN CERTIFICATE-----
|
||||
MIIB1jCCAUGgAwIBAgIFAMv4K9YwCwYJKoZIhvcNAQELMCkxEDAOBgNVBAoTB0Fj
|
||||
bWUgQ28xFTATBgNVBAMTDEVkZGFyZCBTdGFyazAeFw0xNTA1MDYwMzU2NDBaFw0x
|
||||
NjA1MDYwMzU2NDBaMCUxEDAOBgNVBAoTB0FjbWUgQ28xETAPBgNVBAMTCEpvbiBT
|
||||
bm93MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDK6NU0R0eiCYVquU4RcjKc
|
||||
LzGfx0aa1lMr2TnLQUSeLFZHFxsyyMXXuMPig3HK4A7SGFHupO+/1H/sL4xpH5zg
|
||||
8+Zg2r8xnnney7abxcuv0uATWSIeKlNnb1ZO1BAxFnESc3GtyOCr2dUwZHX5mRVP
|
||||
+Zxp2ni5qHNraf3wE2VPIQIDAQABoxIwEDAOBgNVHQ8BAf8EBAMCAKAwCwYJKoZI
|
||||
hvcNAQELA4GBAIr2F7wsqmEU/J/kLyrCgEVXgaV/sKZq4pPNnzS0tBYk8fkV3V18
|
||||
sBJyHKRLL/wFZASvzDcVGCplXyMdAOCyfd8jO3F9Ac/xdlz10RrHJT75hNu3a7/n
|
||||
9KNwKhfN4A1CQv2x372oGjRhCW5bHNCWx4PIVeNzCyq/KZhyY9sxHE1g
|
||||
-----END CERTIFICATE-----`
|
||||
|
||||
// testADTLSValidation is a helper function that runs AD TLS validation tests
|
||||
func testADTLSValidation(t *testing.T, tt adTLSTestCase) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
// Create fresh backend and storage for each subtest to avoid state bleeding
|
||||
b, store := createBackendWithStorage(t)
|
||||
|
||||
data := map[string]interface{}{
|
||||
"url": tt.url,
|
||||
"binddn": "cn=admin,dc=example,dc=com",
|
||||
"bindpass": "password",
|
||||
"userdn": "ou=users,dc=example,dc=com",
|
||||
"schema": ldaputil.SchemaAD,
|
||||
"insecure_tls": tt.insecureTLS,
|
||||
"starttls": tt.startTLS,
|
||||
"certificate": tt.certificate,
|
||||
"password_policy": tt.passwordPolicy,
|
||||
}
|
||||
|
||||
req := &logical.Request{
|
||||
Operation: logical.UpdateOperation,
|
||||
Path: "config",
|
||||
Storage: store,
|
||||
Data: data,
|
||||
}
|
||||
|
||||
resp, err := b.HandleRequest(ctx, req)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error during config: %v", err)
|
||||
}
|
||||
if resp != nil && resp.IsError() {
|
||||
t.Fatalf("unexpected response error during config: %v", resp.Error())
|
||||
}
|
||||
|
||||
// Try to rotate - this is where TLS validation happens
|
||||
rotateReq := &logical.Request{
|
||||
Operation: logical.UpdateOperation,
|
||||
Path: "config/rotate-root",
|
||||
Storage: store,
|
||||
}
|
||||
|
||||
rotateResp, rotateErr := b.HandleRequest(ctx, rotateReq)
|
||||
|
||||
if tt.expectError {
|
||||
// responseError is returned as logical.ErrorResponse with nil error
|
||||
if rotateResp == nil || !rotateResp.IsError() {
|
||||
t.Fatalf("expected error response, got resp=%v", rotateResp)
|
||||
}
|
||||
errMsg := rotateResp.Error().Error()
|
||||
if !strings.Contains(errMsg, tt.errorContains) {
|
||||
t.Fatalf("expected error containing %q, got: %q", tt.errorContains, errMsg)
|
||||
}
|
||||
} else {
|
||||
// For valid configs, TLS validation passes and we expect a connection error
|
||||
// (no real LDAP server). The backend logs these at ERROR level,
|
||||
// which is expected and does not indicate a test failure.
|
||||
// Assert neither the Go error nor the response error is a TLS validation failure,
|
||||
// proving execution got past the TLS checks.
|
||||
if rotateErr != nil {
|
||||
if isTLSValidationError(rotateErr.Error()) {
|
||||
t.Fatalf("unexpected TLS validation error: %s", rotateErr)
|
||||
}
|
||||
// connection/bind error is expected — TLS validation passed
|
||||
}
|
||||
if rotateResp != nil && rotateResp.IsError() {
|
||||
errMsg := rotateResp.Error().Error()
|
||||
if isTLSValidationError(errMsg) {
|
||||
t.Fatalf("unexpected TLS validation error in response: %s", errMsg)
|
||||
}
|
||||
// connection/bind error in response is expected — TLS validation passed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// isTLSValidationError checks if an error message is a TLS validation error
|
||||
func isTLSValidationError(msg string) bool {
|
||||
return strings.Contains(msg, "AD password rotation with ldap:// requires starttls=true for encrypted connection") ||
|
||||
strings.Contains(msg, "AD password rotation requires ldap:// or ldaps:// protocol, got:")
|
||||
}
|
||||
|
||||
// TestValidateADRotationURLs tests the URL validation logic for AD password rotation
|
||||
func TestValidateADRotationURLs(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
urlString string
|
||||
startTLS bool
|
||||
wantErr bool
|
||||
errMsg string
|
||||
}{
|
||||
{
|
||||
name: "single ldaps URL - valid",
|
||||
urlString: "ldaps://secure.example.com",
|
||||
startTLS: false,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "single ldap URL with StartTLS - valid",
|
||||
urlString: "ldap://example.com",
|
||||
startTLS: true,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "single ldap URL without StartTLS - invalid",
|
||||
urlString: "ldap://example.com",
|
||||
startTLS: false,
|
||||
wantErr: true,
|
||||
errMsg: "starttls=true",
|
||||
},
|
||||
{
|
||||
name: "multiple ldaps URLs - valid",
|
||||
urlString: "ldaps://server1.example.com,ldaps://server2.example.com",
|
||||
startTLS: false,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "multiple ldap URLs with StartTLS - valid",
|
||||
urlString: "ldap://server1.example.com,ldap://server2.example.com",
|
||||
startTLS: true,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "mixed ldaps and ldap with StartTLS - valid",
|
||||
urlString: "ldaps://server1.example.com,ldap://server2.example.com",
|
||||
startTLS: true,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "mixed ldaps and ldap without StartTLS - invalid",
|
||||
urlString: "ldaps://server1.example.com,ldap://server2.example.com",
|
||||
startTLS: false,
|
||||
wantErr: true,
|
||||
errMsg: "starttls=true",
|
||||
},
|
||||
{
|
||||
name: "multiple ldap URLs without StartTLS - invalid",
|
||||
urlString: "ldap://server1.example.com,ldap://server2.example.com",
|
||||
startTLS: false,
|
||||
wantErr: true,
|
||||
errMsg: "starttls=true",
|
||||
},
|
||||
{
|
||||
name: "invalid protocol - invalid",
|
||||
urlString: "http://example.com",
|
||||
startTLS: false,
|
||||
wantErr: true,
|
||||
errMsg: "ldap:// or ldaps://",
|
||||
},
|
||||
{
|
||||
name: "empty URL - invalid",
|
||||
urlString: "",
|
||||
startTLS: false,
|
||||
wantErr: true,
|
||||
errMsg: "requires a configured URL",
|
||||
},
|
||||
{
|
||||
name: "URL with spaces - valid",
|
||||
urlString: "ldaps://server1.example.com, ldaps://server2.example.com",
|
||||
startTLS: false,
|
||||
wantErr: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := validateADRotationURLs(tt.urlString, tt.startTLS)
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Errorf("validateADRotationURLs() expected error but got none")
|
||||
return
|
||||
}
|
||||
if tt.errMsg != "" && !strings.Contains(err.Error(), tt.errMsg) {
|
||||
t.Errorf("validateADRotationURLs() error = %v, want error containing %q", err, tt.errMsg)
|
||||
}
|
||||
} else {
|
||||
if err != nil {
|
||||
t.Errorf("validateADRotationURLs() unexpected error = %v", err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
113
builtin/credential/ldap/path_config_test.go
Normal file
113
builtin/credential/ldap/path_config_test.go
Normal file
@ -0,0 +1,113 @@
|
||||
// Copyright IBM Corp. 2016, 2025
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
|
||||
package ldap
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/hashicorp/vault/sdk/helper/ldaputil"
|
||||
"github.com/hashicorp/vault/sdk/logical"
|
||||
)
|
||||
|
||||
// TestConfig_UpgradePath_Schema verifies that a ConfigEntry written to storage
|
||||
// before the "schema" field existed (i.e. Schema == "") is transparently
|
||||
// upgraded to the default value (SchemaOpenLDAP) when read back via Config().
|
||||
// This mirrors the upgrade handling already in place for CaseSensitiveNames
|
||||
// and UsePre111GroupCNBehavior.
|
||||
func TestConfig_UpgradePath_Schema(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
b, store := createBackendWithStorage(t)
|
||||
|
||||
// Simulate a pre-existing stored config that has no "schema" key —
|
||||
// i.e. data written before the field was introduced. We do this by
|
||||
// marshalling a map that deliberately omits the field.
|
||||
oldEntry := map[string]interface{}{
|
||||
"url": "ldap://127.0.0.1",
|
||||
"userdn": "ou=People,dc=example,dc=org",
|
||||
"binddn": "cn=admin,dc=example,dc=org",
|
||||
"bindpass": "secret",
|
||||
// "schema" intentionally absent
|
||||
"CaseSensitiveNames": false,
|
||||
"use_pre111_group_cn_behavior": true,
|
||||
}
|
||||
raw, err := json.Marshal(oldEntry)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to marshal old config entry: %s", err)
|
||||
}
|
||||
entry := &logical.StorageEntry{
|
||||
Key: "config",
|
||||
Value: raw,
|
||||
}
|
||||
if err := store.Put(ctx, entry); err != nil {
|
||||
t.Fatalf("failed to write legacy config to storage: %s", err)
|
||||
}
|
||||
|
||||
// Read back via Config() — this should trigger the upgrade path.
|
||||
req := &logical.Request{Storage: store}
|
||||
cfg, err := b.Config(ctx, req)
|
||||
if err != nil {
|
||||
t.Fatalf("Config() returned unexpected error: %s", err)
|
||||
}
|
||||
if cfg == nil {
|
||||
t.Fatal("Config() returned nil; expected a valid ConfigEntry")
|
||||
}
|
||||
|
||||
// Schema must be defaulted to SchemaOpenLDAP, not left as "".
|
||||
if cfg.Schema != ldaputil.SchemaOpenLDAP {
|
||||
t.Errorf("expected Schema=%q after upgrade, got %q", ldaputil.SchemaOpenLDAP, cfg.Schema)
|
||||
}
|
||||
|
||||
// Verify the migrated value was persisted to storage.
|
||||
persisted, err := store.Get(ctx, "config")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read persisted config: %s", err)
|
||||
}
|
||||
if persisted == nil {
|
||||
t.Fatal("expected config to be re-persisted after upgrade, but nothing found in storage")
|
||||
}
|
||||
var persistedMap map[string]interface{}
|
||||
if err := json.Unmarshal(persisted.Value, &persistedMap); err != nil {
|
||||
t.Fatalf("failed to decode persisted config: %s", err)
|
||||
}
|
||||
if persistedMap["schema"] != ldaputil.SchemaOpenLDAP {
|
||||
t.Errorf("persisted schema=%q; expected %q", persistedMap["schema"], ldaputil.SchemaOpenLDAP)
|
||||
}
|
||||
}
|
||||
|
||||
// TestConfig_UpgradePath_Schema_NoOverwrite ensures that an already-set schema
|
||||
// value is not overwritten by the upgrade logic.
|
||||
func TestConfig_UpgradePath_Schema_NoOverwrite(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
b, store := createBackendWithStorage(t)
|
||||
|
||||
oldEntry := map[string]interface{}{
|
||||
"url": "ldap://127.0.0.1",
|
||||
"userdn": "ou=People,dc=example,dc=org",
|
||||
"binddn": "cn=admin,dc=example,dc=org",
|
||||
"bindpass": "secret",
|
||||
"schema": ldaputil.SchemaAD, // explicitly set to AD
|
||||
"CaseSensitiveNames": false,
|
||||
"use_pre111_group_cn_behavior": true,
|
||||
}
|
||||
raw, err := json.Marshal(oldEntry)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to marshal config entry: %s", err)
|
||||
}
|
||||
entry := &logical.StorageEntry{Key: "config", Value: raw}
|
||||
if err := store.Put(ctx, entry); err != nil {
|
||||
t.Fatalf("failed to write config to storage: %s", err)
|
||||
}
|
||||
|
||||
req := &logical.Request{Storage: store}
|
||||
cfg, err := b.Config(ctx, req)
|
||||
if err != nil {
|
||||
t.Fatalf("Config() returned unexpected error: %s", err)
|
||||
}
|
||||
|
||||
if cfg.Schema != ldaputil.SchemaAD {
|
||||
t.Errorf("expected Schema=%q to be preserved, got %q", ldaputil.SchemaAD, cfg.Schema)
|
||||
}
|
||||
}
|
||||
4
changelog/_12223.txt
Normal file
4
changelog/_12223.txt
Normal file
@ -0,0 +1,4 @@
|
||||
```release-note:bug
|
||||
ldap auth (enterprise): Fix root password rotation for Active Directory by implementing UTF-16LE encoding and schema-specific handling. Adds new 'schema' config field (defaults to 'openldap' for backward compatibility).
|
||||
```
|
||||
|
||||
@ -9,6 +9,7 @@ import (
|
||||
"fmt"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@ -17,6 +18,24 @@ import (
|
||||
"github.com/hashicorp/vault/sdk/helper/ldaputil"
|
||||
)
|
||||
|
||||
// safeBuffer is a thread-safe bytes.Buffer wrapper
|
||||
type safeBuffer struct {
|
||||
buf bytes.Buffer
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func (s *safeBuffer) Write(p []byte) (n int, err error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.buf.Write(p)
|
||||
}
|
||||
|
||||
func (s *safeBuffer) String() string {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.buf.String()
|
||||
}
|
||||
|
||||
// DefaultVersion is the default version of the container to pull.
|
||||
// NOTE: This is currently pinned to a sha instead of "master", see: https://github.com/rroemhild/docker-test-openldap/issues/62
|
||||
const DefaultVersion = "sha256:f4d9c5ba97f9662e9aea082b4aa89233994ca6e232abc1952d5d90da7e16b0eb"
|
||||
@ -28,7 +47,7 @@ func PrepareTestContainer(t *testing.T, version string) (cleanup func(), cfg *ld
|
||||
t.Skip("Skipping, as this image is not supported on ARM architectures")
|
||||
}
|
||||
|
||||
logsWriter := bytes.NewBuffer([]byte{})
|
||||
logsWriter := &safeBuffer{}
|
||||
|
||||
runner, err := docker.NewServiceRunner(docker.RunOptions{
|
||||
ImageRepo: "ghcr.io/rroemhild/docker-test-openldap",
|
||||
|
||||
@ -17,6 +17,7 @@ import (
|
||||
"github.com/hashicorp/errwrap"
|
||||
"github.com/hashicorp/go-secure-stdlib/tlsutil"
|
||||
"github.com/hashicorp/vault/sdk/framework"
|
||||
"github.com/hashicorp/vault/sdk/helper/strutil"
|
||||
)
|
||||
|
||||
var ldapDerefAliasMap = map[string]int{
|
||||
@ -26,6 +27,18 @@ var ldapDerefAliasMap = map[string]int{
|
||||
"always": ldap.DerefAlways,
|
||||
}
|
||||
|
||||
const (
|
||||
SchemaAD = "ad"
|
||||
SchemaOpenLDAP = "openldap"
|
||||
)
|
||||
|
||||
// SupportedSchemas returns a slice of different LDAP schemas supported
|
||||
// by the plugin. This is used to change behavior when modifying
|
||||
// user passwords (for example, during root password rotation).
|
||||
func SupportedSchemas() []string {
|
||||
return []string{SchemaOpenLDAP, SchemaAD}
|
||||
}
|
||||
|
||||
// ConfigFields returns all the config fields that can potentially be used by the LDAP client.
|
||||
// Not all fields will be used by every integration.
|
||||
func ConfigFields() map[string]*framework.FieldSchema {
|
||||
@ -287,6 +300,11 @@ Default: ({{.UserAttr}}={{.Username}})`,
|
||||
Description: "If true, matching sAMAccountName attribute values will be allowed to login when upndomain is defined.",
|
||||
Default: false,
|
||||
},
|
||||
"schema": {
|
||||
Type: framework.TypeString,
|
||||
Description: "LDAP schema type: 'ad' for Active Directory, 'openldap' for OpenLDAP. Determines root password rotation behavior.",
|
||||
Default: SchemaOpenLDAP,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@ -468,6 +486,14 @@ func NewConfigEntry(existing *ConfigEntry, d *framework.FieldData) (*ConfigEntry
|
||||
if _, ok := d.Raw["enable_samaccountname_login"]; ok || !hadExisting {
|
||||
cfg.EnableSamaccountnameLogin = d.Get("enable_samaccountname_login").(bool)
|
||||
}
|
||||
if _, ok := d.Raw["schema"]; ok || !hadExisting {
|
||||
rawSchema := d.Get("schema").(string)
|
||||
cfg.Schema = NormalizedSchema(rawSchema)
|
||||
// Validate the normalized schema, not the raw input string, to allow for case-insensitive input while still enforcing valid schema types.
|
||||
if !strutil.StrListContains(SupportedSchemas(), cfg.Schema) {
|
||||
return nil, fmt.Errorf("unsupported schema type %q: must be one of %v", rawSchema, SupportedSchemas())
|
||||
}
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
@ -498,6 +524,7 @@ type ConfigEntry struct {
|
||||
ConnectionTimeout int `json:"connection_timeout"` // deprecated: use RequestTimeout
|
||||
DerefAliases string `json:"dereference_aliases"`
|
||||
MaximumPageSize int `json:"max_page_size"`
|
||||
Schema string `json:"schema"`
|
||||
|
||||
// These json tags deviate from snake case because there was a past issue
|
||||
// where the tag was being ignored, causing it to be jsonified as "CaseSensitiveNames", etc.
|
||||
@ -541,6 +568,7 @@ func (c *ConfigEntry) PasswordlessMap() map[string]interface{} {
|
||||
"dereference_aliases": c.DerefAliases,
|
||||
"max_page_size": c.MaximumPageSize,
|
||||
"enable_samaccountname_login": c.EnableSamaccountnameLogin,
|
||||
"schema": NormalizedSchema(c.Schema), // LDAP schema type for password operations
|
||||
}
|
||||
if c.CaseSensitiveNames != nil {
|
||||
m["case_sensitive_names"] = *c.CaseSensitiveNames
|
||||
@ -593,6 +621,10 @@ func (c *ConfigEntry) Validate() error {
|
||||
return errwrap.Wrapf("failed to parse client X509 key pair: {{err}}", err)
|
||||
}
|
||||
}
|
||||
normalizedSchema := NormalizedSchema(c.Schema)
|
||||
if !strutil.StrListContains(SupportedSchemas(), normalizedSchema) {
|
||||
return fmt.Errorf("unsupported schema type %q: must be one of %v", c.Schema, SupportedSchemas())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -648,3 +680,11 @@ func min(a, b int) int {
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func NormalizedSchema(schema string) string {
|
||||
normalizedSchema := strings.ToLower(strings.TrimSpace(schema))
|
||||
if normalizedSchema == "" {
|
||||
return SchemaOpenLDAP
|
||||
}
|
||||
return normalizedSchema
|
||||
}
|
||||
|
||||
@ -5,6 +5,7 @@ package ldaputil
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/go-test/deep"
|
||||
@ -68,6 +69,292 @@ func TestConfig(t *testing.T) {
|
||||
t.Errorf("expected false UseTokenGroups from JSON but got %t", configFromJSON.UseTokenGroups)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("default_schema_type", func(t *testing.T) {
|
||||
if config.Schema != SchemaOpenLDAP {
|
||||
t.Errorf("expected default Schema %s but got %s", SchemaOpenLDAP, config.Schema)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestNormalizedSchema(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "empty_string_defaults_to_openldap",
|
||||
input: "",
|
||||
expected: SchemaOpenLDAP,
|
||||
},
|
||||
{
|
||||
name: "lowercase_ad",
|
||||
input: "ad",
|
||||
expected: SchemaAD,
|
||||
},
|
||||
{
|
||||
name: "uppercase_AD",
|
||||
input: "AD",
|
||||
expected: SchemaAD,
|
||||
},
|
||||
{
|
||||
name: "mixed_case_Ad",
|
||||
input: "Ad",
|
||||
expected: SchemaAD,
|
||||
},
|
||||
{
|
||||
name: "lowercase_openldap",
|
||||
input: "openldap",
|
||||
expected: SchemaOpenLDAP,
|
||||
},
|
||||
{
|
||||
name: "uppercase_OPENLDAP",
|
||||
input: "OPENLDAP",
|
||||
expected: SchemaOpenLDAP,
|
||||
},
|
||||
{
|
||||
name: "mixed_case_OpenLDAP",
|
||||
input: "OpenLDAP",
|
||||
expected: SchemaOpenLDAP,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := NormalizedSchema(tt.input)
|
||||
if result != tt.expected {
|
||||
t.Errorf("NormalizedSchema(%q) = %q, want %q", tt.input, result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSchemaInConfigEntry(t *testing.T) {
|
||||
t.Run("new_config_defaults_to_openldap", func(t *testing.T) {
|
||||
s := &framework.FieldData{Schema: ConfigFields()}
|
||||
config, err := NewConfigEntry(nil, s)
|
||||
if err != nil {
|
||||
t.Fatal("error getting default config")
|
||||
}
|
||||
|
||||
if config.Schema != SchemaOpenLDAP {
|
||||
t.Errorf("expected default Schema %s but got %s", SchemaOpenLDAP, config.Schema)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("schema_normalized_on_create", func(t *testing.T) {
|
||||
schema := ConfigFields()
|
||||
data := &framework.FieldData{
|
||||
Raw: map[string]interface{}{
|
||||
"schema": "AD",
|
||||
},
|
||||
Schema: schema,
|
||||
}
|
||||
|
||||
config, err := NewConfigEntry(nil, data)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if config.Schema != SchemaAD {
|
||||
t.Errorf("expected normalized Schema 'ad' but got %s", config.Schema)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("schema_in_passwordless_map", func(t *testing.T) {
|
||||
config := &ConfigEntry{
|
||||
Schema: SchemaAD,
|
||||
}
|
||||
|
||||
m := config.PasswordlessMap()
|
||||
schema, ok := m["schema"]
|
||||
if !ok {
|
||||
t.Error("schema not found in PasswordlessMap")
|
||||
}
|
||||
|
||||
if schema != SchemaAD {
|
||||
t.Errorf("expected schema %s in map but got %v", SchemaAD, schema)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("schema_update_existing_config", func(t *testing.T) {
|
||||
existingConfig := &ConfigEntry{
|
||||
Url: "ldap://127.0.0.1",
|
||||
Schema: SchemaOpenLDAP,
|
||||
}
|
||||
|
||||
schema := ConfigFields()
|
||||
data := &framework.FieldData{
|
||||
Raw: map[string]interface{}{
|
||||
"schema": "AD",
|
||||
},
|
||||
Schema: schema,
|
||||
}
|
||||
|
||||
updatedConfig, err := NewConfigEntry(existingConfig, data)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if updatedConfig.Schema != SchemaAD {
|
||||
t.Errorf("expected updated Schema 'ad' but got %s", updatedConfig.Schema)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestSupportedSchemas(t *testing.T) {
|
||||
schemas := SupportedSchemas()
|
||||
|
||||
expectedSchemas := []string{SchemaOpenLDAP, SchemaAD}
|
||||
if len(schemas) != len(expectedSchemas) {
|
||||
t.Errorf("expected %d schemas but got %d", len(expectedSchemas), len(schemas))
|
||||
}
|
||||
|
||||
for _, expected := range expectedSchemas {
|
||||
found := false
|
||||
for _, schema := range schemas {
|
||||
if schema == expected {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("expected schema %q not found in SupportedSchemas()", expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSchemaValidation(t *testing.T) {
|
||||
t.Run("valid_openldap_schema", func(t *testing.T) {
|
||||
config := &ConfigEntry{
|
||||
Url: "ldap://127.0.0.1",
|
||||
UserDN: "ou=users,dc=example,dc=com",
|
||||
Schema: SchemaOpenLDAP,
|
||||
TLSMinVersion: "tls12",
|
||||
TLSMaxVersion: "tls12",
|
||||
}
|
||||
|
||||
if err := config.Validate(); err != nil {
|
||||
t.Errorf("expected no error for valid openldap schema, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("valid_ad_schema", func(t *testing.T) {
|
||||
config := &ConfigEntry{
|
||||
Url: "ldap://127.0.0.1",
|
||||
UserDN: "ou=users,dc=example,dc=com",
|
||||
Schema: SchemaAD,
|
||||
TLSMinVersion: "tls12",
|
||||
TLSMaxVersion: "tls12",
|
||||
}
|
||||
|
||||
if err := config.Validate(); err != nil {
|
||||
t.Errorf("expected no error for valid ad schema, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty_schema_passes_validation", func(t *testing.T) {
|
||||
config := &ConfigEntry{
|
||||
Url: "ldap://127.0.0.1",
|
||||
UserDN: "ou=users,dc=example,dc=com",
|
||||
Schema: "",
|
||||
TLSMinVersion: "tls12",
|
||||
TLSMaxVersion: "tls12",
|
||||
}
|
||||
|
||||
if err := config.Validate(); err != nil {
|
||||
t.Errorf("expected no error for empty schema (defaults to openldap), got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("generic_unsupported_schema_fails_validation", func(t *testing.T) {
|
||||
config := &ConfigEntry{
|
||||
Url: "ldap://127.0.0.1",
|
||||
UserDN: "ou=users,dc=example,dc=com",
|
||||
Schema: "unsupported_schema",
|
||||
TLSMinVersion: "tls12",
|
||||
TLSMaxVersion: "tls12",
|
||||
}
|
||||
|
||||
err := config.Validate()
|
||||
if err == nil {
|
||||
t.Error("expected error for unsupported schema, got nil")
|
||||
}
|
||||
|
||||
if err != nil && !strings.Contains(err.Error(), "unsupported schema") {
|
||||
t.Errorf("expected error message to contain 'unsupported schema', got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("freeipa_schema_fails_validation", func(t *testing.T) {
|
||||
config := &ConfigEntry{
|
||||
Url: "ldap://127.0.0.1",
|
||||
UserDN: "ou=users,dc=example,dc=com",
|
||||
Schema: "freeipa",
|
||||
TLSMinVersion: "tls12",
|
||||
TLSMaxVersion: "tls12",
|
||||
}
|
||||
|
||||
err := config.Validate()
|
||||
if err == nil {
|
||||
t.Error("expected error for unsupported schema 'freeipa', got nil")
|
||||
}
|
||||
|
||||
if err != nil && !strings.Contains(err.Error(), "freeipa") {
|
||||
t.Errorf("expected error message to include 'freeipa', got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("typo_in_schema_fails_validation", func(t *testing.T) {
|
||||
// Test common typos like "adc" instead of "ad"
|
||||
config := &ConfigEntry{
|
||||
Url: "ldap://127.0.0.1",
|
||||
UserDN: "ou=users,dc=example,dc=com",
|
||||
Schema: "adc",
|
||||
TLSMinVersion: "tls12",
|
||||
TLSMaxVersion: "tls12",
|
||||
}
|
||||
|
||||
err := config.Validate()
|
||||
if err == nil {
|
||||
t.Error("expected error for typo schema 'adc', got nil")
|
||||
}
|
||||
|
||||
// Verify error message is helpful
|
||||
if err != nil && !strings.Contains(err.Error(), "unsupported schema") {
|
||||
t.Errorf("expected error message to contain 'unsupported schema', got: %v", err)
|
||||
}
|
||||
if err != nil && !strings.Contains(err.Error(), "adc") {
|
||||
t.Errorf("expected error message to include the unsupported value 'adc', got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("normalized_unsupported_schema_fails_validation", func(t *testing.T) {
|
||||
// Test that even after normalization (lowercase), unsupported schemas fail
|
||||
schema := ConfigFields()
|
||||
data := &framework.FieldData{
|
||||
Raw: map[string]interface{}{
|
||||
"url": "ldap://127.0.0.1",
|
||||
"userdn": "ou=users,dc=example,dc=com",
|
||||
"schema": "UNSUPPORTED_SCHEMAS",
|
||||
},
|
||||
Schema: schema,
|
||||
}
|
||||
|
||||
// NewConfigEntry should fail because schema validation happens during creation
|
||||
_, err := NewConfigEntry(nil, data)
|
||||
if err == nil {
|
||||
t.Error("expected error from NewConfigEntry for unsupported schema, got nil")
|
||||
}
|
||||
|
||||
// Verify the error message is helpful
|
||||
if err != nil {
|
||||
if !strings.Contains(err.Error(), "unsupported schema") {
|
||||
t.Errorf("expected error message to contain %q, got: %v", "unsupported schema", err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func testConfig(t *testing.T) *ConfigEntry {
|
||||
@ -84,6 +371,7 @@ func testConfig(t *testing.T) *ConfigEntry {
|
||||
ConnectionTimeout: 15,
|
||||
ClientTLSCert: "",
|
||||
ClientTLSKey: "",
|
||||
Schema: SchemaOpenLDAP,
|
||||
}
|
||||
}
|
||||
|
||||
@ -144,7 +432,8 @@ var jsonConfig = []byte(`{
|
||||
"request_timeout": 30,
|
||||
"connection_timeout": 15,
|
||||
"ClientTLSCert": "",
|
||||
"ClientTLSKey": ""
|
||||
"ClientTLSKey": "",
|
||||
"schema": "openldap"
|
||||
}`)
|
||||
|
||||
var jsonConfigDefault = []byte(`
|
||||
@ -179,6 +468,7 @@ var jsonConfigDefault = []byte(`
|
||||
"CaseSensitiveNames": false,
|
||||
"ClientTLSCert": "",
|
||||
"ClientTLSKey": "",
|
||||
"enable_samaccountname_login": false
|
||||
"enable_samaccountname_login": false,
|
||||
"schema": "openldap"
|
||||
}
|
||||
`)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user