diff --git a/changelog/21641.txt b/changelog/21641.txt new file mode 100644 index 000000000000..b9fb57bb57ef --- /dev/null +++ b/changelog/21641.txt @@ -0,0 +1,3 @@ +```release-note:feature +auto-auth: support ldap auth +``` diff --git a/command/agentproxyshared/auth/ldap/ldap.go b/command/agentproxyshared/auth/ldap/ldap.go new file mode 100644 index 000000000000..d286708b00fb --- /dev/null +++ b/command/agentproxyshared/auth/ldap/ldap.go @@ -0,0 +1,259 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package ldap + +import ( + "context" + "errors" + "fmt" + "io/fs" + "net/http" + "os" + "path/filepath" + "sync" + "sync/atomic" + "time" + + hclog "github.com/hashicorp/go-hclog" + "github.com/hashicorp/vault/api" + "github.com/hashicorp/vault/command/agentproxyshared/auth" + "github.com/hashicorp/vault/sdk/helper/parseutil" +) + +type ldapMethod struct { + logger hclog.Logger + mountPath string + + username string + passwordFilePath string + removePasswordAfterReading bool + removePasswordFollowsSymlinks bool + credsFound chan struct{} + watchCh chan string + stopCh chan struct{} + doneCh chan struct{} + credSuccessGate chan struct{} + ticker *time.Ticker + once *sync.Once + latestPass *atomic.Value +} + +// NewLdapMethod reads the user configuration and returns a configured +// LdapAuthMethod +func NewLdapAuthMethod(conf *auth.AuthConfig) (auth.AuthMethod, error) { + if conf == nil { + return nil, errors.New("empty config") + } + if conf.Config == nil { + return nil, errors.New("empty config data") + } + + k := &ldapMethod{ + logger: conf.Logger, + mountPath: conf.MountPath, + removePasswordAfterReading: true, + credsFound: make(chan struct{}), + watchCh: make(chan string), + stopCh: make(chan struct{}), + doneCh: make(chan struct{}), + credSuccessGate: make(chan struct{}), + once: new(sync.Once), + latestPass: new(atomic.Value), + } + + k.latestPass.Store("") + usernameRaw, ok := conf.Config["username"] + if !ok { + return nil, errors.New("missing 'username' value") + } + k.username, ok = usernameRaw.(string) + if !ok { + return nil, errors.New("could not convert 'username' config value to string") + } + + passFilePathRaw, ok := conf.Config["password_file_path"] + if !ok { + return nil, errors.New("missing 'password_file_path' value") + } + k.passwordFilePath, ok = passFilePathRaw.(string) + if !ok { + return nil, errors.New("could not convert 'password_file_path' config value to string") + } + if removePassAfterReadingRaw, ok := conf.Config["remove_password_after_reading"]; ok { + removePassAfterReading, err := parseutil.ParseBool(removePassAfterReadingRaw) + if err != nil { + return nil, fmt.Errorf("error parsing 'remove_password_after_reading' value: %w", err) + } + k.removePasswordAfterReading = removePassAfterReading + } + + if removePassFollowsSymlinksRaw, ok := conf.Config["remove_password_follows_symlinks"]; ok { + removePassFollowsSymlinks, err := parseutil.ParseBool(removePassFollowsSymlinksRaw) + if err != nil { + return nil, fmt.Errorf("error parsing 'remove_password_follows_symlinks' value: %w", err) + } + k.removePasswordFollowsSymlinks = removePassFollowsSymlinks + } + switch { + case k.passwordFilePath == "": + return nil, errors.New("'password_file_path' value is empty") + case k.username == "": + return nil, errors.New("'username' value is empty") + } + + // Default readPeriod + readPeriod := 1 * time.Minute + + if passReadPeriodRaw, ok := conf.Config["password_read_period"]; ok { + passReadPeriod, err := parseutil.ParseDurationSecond(passReadPeriodRaw) + if err != nil { + return nil, fmt.Errorf("error parsing 'pass_read_period' value: %w", err) + } + readPeriod = passReadPeriod + } else { + // If we don't delete the password after reading, use a slower reload period, + // otherwise we would re-read the whole file every 500ms, instead of just + // doing a stat on the file every 500ms. + if k.removePasswordAfterReading { + readPeriod = 500 * time.Millisecond + } + } + + k.ticker = time.NewTicker(readPeriod) + + go k.runWatcher() + + k.logger.Info("ldap auth method created", "password_file_path", k.passwordFilePath) + + return k, nil +} + +func (k *ldapMethod) Authenticate(ctx context.Context, client *api.Client) (string, http.Header, map[string]interface{}, error) { + k.logger.Trace("beginning authentication") + + k.ingressPass() + + latestPass := k.latestPass.Load().(string) + + if latestPass == "" { + return "", nil, nil, errors.New("latest known password is empty, cannot authenticate") + } + k.logger.Info("last known password in Authentication setup is") + return fmt.Sprintf("%s/login/%s", k.mountPath, k.username), nil, map[string]interface{}{ + "password": latestPass, + }, nil +} + +func (k *ldapMethod) NewCreds() chan struct{} { + return k.credsFound +} + +func (k *ldapMethod) CredSuccess() { + k.once.Do(func() { + close(k.credSuccessGate) + }) +} + +func (k *ldapMethod) Shutdown() { + k.ticker.Stop() + close(k.stopCh) + <-k.doneCh +} + +func (k *ldapMethod) runWatcher() { + defer close(k.doneCh) + + select { + case <-k.stopCh: + return + + case <-k.credSuccessGate: + // We only start the next loop once we're initially successful, + // since at startup Authenticate will be called, and we don't want + // to end up immediately re-authenticating by having found a new + // value + } + + for { + select { + case <-k.stopCh: + return + + case <-k.ticker.C: + latestPass := k.latestPass.Load().(string) + k.ingressPass() + newPass := k.latestPass.Load().(string) + if newPass != latestPass { + k.logger.Debug("new password file found") + k.credsFound <- struct{}{} + } + } + } +} + +func (k *ldapMethod) ingressPass() { + fi, err := os.Lstat(k.passwordFilePath) + if err != nil { + if os.IsNotExist(err) { + return + } + k.logger.Error("error encountered stat'ing password file", "error", err) + return + } + + // Check that the path refers to a file. + // If it's a symlink, it could still be a symlink to a directory, + // but os.ReadFile below will return a descriptive error. + evalSymlinkPath := k.passwordFilePath + switch mode := fi.Mode(); { + case mode.IsRegular(): + // regular file + case mode&fs.ModeSymlink != 0: + // If our file path is a symlink, we should also return early (like above) without error + // if the file that is linked to is not present, otherwise we will error when trying + // to read that file by following the link in the os.ReadFile call. + evalSymlinkPath, err = filepath.EvalSymlinks(k.passwordFilePath) + if err != nil { + k.logger.Error("error encountered evaluating symlinks", "error", err) + return + } + _, err := os.Stat(evalSymlinkPath) + if err != nil { + if os.IsNotExist(err) { + return + } + k.logger.Error("error encountered stat'ing password file after evaluating symlinks", "error", err) + return + } + default: + k.logger.Error("password file is not a regular file or symlink") + return + } + + pass, err := os.ReadFile(k.passwordFilePath) + if err != nil { + k.logger.Error("failed to read password file", "error", err) + return + } + + switch len(pass) { + case 0: + k.logger.Warn("empty password file read") + + default: + k.latestPass.Store(string(pass)) + } + + if k.removePasswordAfterReading { + pathToRemove := k.passwordFilePath + if k.removePasswordFollowsSymlinks { + // If removePassFollowsSymlinks is set, we follow the symlink and delete the password, + // not just the symlink that links to the password file + pathToRemove = evalSymlinkPath + } + if err := os.Remove(pathToRemove); err != nil { + k.logger.Error("error removing password file", "error", err) + } + } +} diff --git a/command/agentproxyshared/auth/ldap/ldap_test.go b/command/agentproxyshared/auth/ldap/ldap_test.go new file mode 100644 index 000000000000..a24caf6abc7b --- /dev/null +++ b/command/agentproxyshared/auth/ldap/ldap_test.go @@ -0,0 +1,262 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package ldap + +import ( + "bytes" + "os" + "path" + "strings" + "sync/atomic" + "testing" + + hclog "github.com/hashicorp/go-hclog" + "github.com/hashicorp/vault/command/agentproxyshared/auth" +) + +func TestIngressPass(t *testing.T) { + const ( + dir = "dir" + file = "file" + empty = "empty" + missing = "missing" + symlinked = "symlinked" + ) + + rootDir, err := os.MkdirTemp("", "vault-agent-ldap-auth-test") + if err != nil { + t.Fatalf("failed to create temp dir: %s", err) + } + defer os.RemoveAll(rootDir) + + setupTestDir := func() string { + testDir, err := os.MkdirTemp(rootDir, "") + if err != nil { + t.Fatal(err) + } + err = os.WriteFile(path.Join(testDir, file), []byte("test"), 0o644) + if err != nil { + t.Fatal(err) + } + _, err = os.Create(path.Join(testDir, empty)) + if err != nil { + t.Fatal(err) + } + err = os.Mkdir(path.Join(testDir, dir), 0o755) + if err != nil { + t.Fatal(err) + } + err = os.Symlink(path.Join(testDir, file), path.Join(testDir, symlinked)) + if err != nil { + t.Fatal(err) + } + + return testDir + } + + for _, tc := range []struct { + name string + path string + errString string + }{ + { + "happy path", + file, + "", + }, + { + "path is directory", + dir, + "[ERROR] password file is not a regular file or symlink", + }, + { + "password file path is symlink", + symlinked, + "", + }, + { + "password file path is missing (implies nothing for ingressPass to do)", + missing, + "", + }, + { + "password file path is empty file", + empty, + "[WARN] empty password file read", + }, + } { + testDir := setupTestDir() + logBuffer := bytes.Buffer{} + ldapAuth := &ldapMethod{ + logger: hclog.New(&hclog.LoggerOptions{ + Output: &logBuffer, + }), + latestPass: new(atomic.Value), + passwordFilePath: path.Join(testDir, tc.path), + } + + ldapAuth.ingressPass() + + if tc.errString != "" { + if !strings.Contains(logBuffer.String(), tc.errString) { + t.Fatal("logs did no contain expected error", tc.errString, logBuffer.String()) + } + } else { + if strings.Contains(logBuffer.String(), "[ERROR]") || strings.Contains(logBuffer.String(), "[WARN]") { + t.Fatal("logs contained unexpected error", logBuffer.String()) + } + } + } +} + +func TestDeleteAfterReading(t *testing.T) { + for _, tc := range map[string]struct { + configValue string + shouldDelete bool + }{ + "default": { + "", + true, + }, + "explicit true": { + "true", + true, + }, + "false": { + "false", + false, + }, + } { + rootDir, err := os.MkdirTemp("", "vault-agent-ldap-auth-test") + if err != nil { + t.Fatalf("failed to create temp dir: %s", err) + } + defer os.RemoveAll(rootDir) + passPath := path.Join(rootDir, "pass") + err = os.WriteFile(passPath, []byte("test"), 0o644) + if err != nil { + t.Fatal(err) + } + + config := &auth.AuthConfig{ + Config: map[string]interface{}{ + "password_file_path": passPath, + "username": "testuser", + }, + Logger: hclog.Default(), + } + if tc.configValue != "" { + config.Config["remove_password_after_reading"] = tc.configValue + } + + ldapAuth, err := NewLdapAuthMethod(config) + if err != nil { + t.Fatal(err) + } + + ldapAuth.(*ldapMethod).ingressPass() + + if _, err := os.Lstat(passPath); tc.shouldDelete { + if err == nil || !os.IsNotExist(err) { + t.Fatal(err) + } + } else { + if err != nil { + t.Fatal(err) + } + } + } +} + +func TestDeleteAfterReadingSymlink(t *testing.T) { + for _, tc := range map[string]struct { + configValue string + shouldDelete bool + removePassFollowsSymlinks bool + }{ + "default": { + "", + true, + false, + }, + "explicit true": { + "true", + true, + false, + }, + "false": { + "false", + false, + false, + }, + "default + removePassFollowsSymlinks": { + "", + true, + true, + }, + "explicit true + removePassFollowsSymlinks": { + "true", + true, + true, + }, + "false + removePassFollowsSymlinks": { + "false", + false, + true, + }, + } { + rootDir, err := os.MkdirTemp("", "vault-agent-ldap-auth-test") + if err != nil { + t.Fatalf("failed to create temp dir: %s", err) + } + defer os.RemoveAll(rootDir) + passPath := path.Join(rootDir, "pass") + err = os.WriteFile(passPath, []byte("test"), 0o644) + if err != nil { + t.Fatal(err) + } + + symlink, err := os.CreateTemp("", "auth.ldap.symlink.test.") + if err != nil { + t.Fatal(err) + } + symlinkName := symlink.Name() + symlink.Close() + os.Remove(symlinkName) + os.Symlink(passPath, symlinkName) + + config := &auth.AuthConfig{ + Config: map[string]interface{}{ + "password_file_path": symlinkName, + "username": "testuser", + }, + Logger: hclog.Default(), + } + if tc.configValue != "" { + config.Config["remove_password_after_reading"] = tc.configValue + } + config.Config["remove_password_follows_symlinks"] = tc.removePassFollowsSymlinks + + ldapAuth, err := NewLdapAuthMethod(config) + if err != nil { + t.Fatal(err) + } + + ldapAuth.(*ldapMethod).ingressPass() + + pathToCheck := symlinkName + if tc.removePassFollowsSymlinks { + pathToCheck = passPath + } + if _, err := os.Lstat(pathToCheck); tc.shouldDelete { + if err == nil || !os.IsNotExist(err) { + t.Fatal(err) + } + } else { + if err != nil { + t.Fatal(err) + } + } + } +} diff --git a/command/agentproxyshared/helpers.go b/command/agentproxyshared/helpers.go index 855a105545e1..f1ef47cfee33 100644 --- a/command/agentproxyshared/helpers.go +++ b/command/agentproxyshared/helpers.go @@ -23,6 +23,7 @@ import ( "github.com/hashicorp/vault/command/agentproxyshared/auth/jwt" "github.com/hashicorp/vault/command/agentproxyshared/auth/kerberos" "github.com/hashicorp/vault/command/agentproxyshared/auth/kubernetes" + "github.com/hashicorp/vault/command/agentproxyshared/auth/ldap" "github.com/hashicorp/vault/command/agentproxyshared/auth/oci" token_file "github.com/hashicorp/vault/command/agentproxyshared/auth/token-file" "github.com/hashicorp/vault/command/agentproxyshared/cache" @@ -62,6 +63,8 @@ func GetAutoAuthMethodFromConfig(autoAuthMethodType string, authConfig *auth.Aut return token_file.NewTokenFileAuthMethod(authConfig) case "pcf": // Deprecated. return cf.NewCFAuthMethod(authConfig) + case "ldap": + return ldap.NewLdapAuthMethod(authConfig) default: return nil, errors.New(fmt.Sprintf("unknown auth method %q", autoAuthMethodType)) } diff --git a/website/content/docs/agent-and-proxy/autoauth/methods/ldap.mdx b/website/content/docs/agent-and-proxy/autoauth/methods/ldap.mdx new file mode 100644 index 000000000000..f2569e378fd7 --- /dev/null +++ b/website/content/docs/agent-and-proxy/autoauth/methods/ldap.mdx @@ -0,0 +1,32 @@ +--- +layout: docs +page_title: Vault Auto-Auth LDAP Method +description: LDAP Method for Vault Auto-Auth +--- + +# Vault Auto-Auth LDAP Method + +The `ldap` method reads in a password from a file and sends it to the [LDAP Auth +method](/vault/docs/auth/ldap). + +## Configuration + +- `password_file_path` `(string: required)` - The path to the password file + +- `username` `(string: required)` - The username to authenticate against on Vault + +- `remove_password_after_reading` `(bool: optional, defaults to true)` - + This can be set to `false` to disable the default behavior of removing the + password after it's been read. + +- `remove_password_follows_symlinks` `(bool: optional, defaults to false)` - +This can be set to `true` to follow symlinks when removing the password after +it has been read when executing the `remove_password_after_reading` behaviour. +If set to false, it will delete the symlink, not the password file. Does +nothing if `remove_password_after_reading` is false. + +- `password_read_period` `(duration: "0.5s", optional)` - The duration after which +auto-auth will attempt to read the password stored at `password_file_path`. +Defaults to `1m` if `remove_password_after_reading` is set to `true`, or `0.5s` +otherwise. Uses [duration format +strings](/vault/docs/concepts/duration-format). diff --git a/website/data/docs-nav-data.json b/website/data/docs-nav-data.json index 09a13ded3c62..bdb2beb8ae00 100644 --- a/website/data/docs-nav-data.json +++ b/website/data/docs-nav-data.json @@ -1008,6 +1008,10 @@ "title": "JWT", "path": "agent-and-proxy/autoauth/methods/jwt" }, + { + "title": "LDAP", + "path": "agent-and-proxy/autoauth/methods/ldap" + }, { "title": "Kerberos", "path": "agent-and-proxy/autoauth/methods/kerberos"