# HG changeset patch
# Node ID 16a07378fda7
# Parent  ce6c5df59071
diff --git a/access/access.go b/access/access.go
--- a/access/access.go
+++ b/access/access.go
@@ -26,11 +26,16 @@ var (
 	repositories map[string]string
 )
 
-func Setup(repositoriesPath, adminRepo string) error {
+func Setup(repositoriesPath, adminRepo, ldapConfig string) error {
 	reposPath = repositoriesPath
 	adminRepoName = adminRepo
 	adminRepoPath = filepath.Join(reposPath, adminRepo)
 
+	if err := refreshLdapConfig(ldapConfig); err != nil {
+		zap.S().Error("cannot load ldap config")
+		return err
+	}
+
 	configPath, err := os.CreateTemp("", "hgkeeper-hgweb-access-*.config")
 	if err != nil {
 		return err
diff --git a/access/ldap.go b/access/ldap.go
new file mode 100644
--- /dev/null
+++ b/access/ldap.go
@@ -0,0 +1,105 @@
+package access
+
+import (
+	"encoding/json"
+	"fmt"
+	"os"
+
+	"github.com/go-ldap/ldap/v3"
+	"go.uber.org/zap"
+)
+
+type LdapConfig struct {
+	Username  string `json:"username"`
+	Password  string `json:"password"`
+	Address   string `json:"address"`
+	BaseDN    string `json:"baseDN"`
+	Filter    string `json:"filter"`
+	Attribute string `json:"attribute"`
+}
+
+var (
+	config *LdapConfig = nil
+)
+
+func refreshLdapConfig(path string) error {
+	if path == "" {
+		return nil
+	}
+
+	if _, err := os.Stat(path); err != nil {
+		return err
+	}
+
+	data, err := os.ReadFile(path)
+	if err != nil {
+		return err
+	}
+
+	return json.Unmarshal(data, &config)
+}
+
+func searchLdapPubkey(pubkey string) string {
+	if config == nil {
+		return ""
+	}
+
+	connection, err := connect()
+	if err == nil {
+		defer connection.Close()
+		result, err := bindAndSearch(connection, pubkey)
+		if err == nil {
+			return result
+		}
+	}
+
+	zap.S().Warn(err)
+	return ""
+}
+
+func connect() (*ldap.Conn, error) {
+	connection, err := ldap.DialURL(config.Address)
+	if err != nil {
+		return nil, err
+	}
+
+	return connection, nil
+}
+
+func bindAndSearch(connection *ldap.Conn, pubkey string) (string, error) {
+	err := bind(connection)
+	if err != nil {
+		return "", err
+	}
+
+	request := ldap.NewSearchRequest(
+		config.BaseDN,
+		ldap.ScopeWholeSubtree,
+		ldap.NeverDerefAliases,
+		0,
+		0,
+		false,
+		fmt.Sprintf(config.Filter, pubkey),
+		[]string{config.Attribute},
+		nil,
+	)
+
+	result, err := connection.Search(request)
+	if err != nil {
+		return "", err
+	}
+
+	if len(result.Entries) != 1 {
+		return "", fmt.Errorf("cannot find unique user for: %s", pubkey)
+	}
+
+	return result.Entries[0].GetAttributeValue(config.Attribute), nil
+}
+
+func bind(connection *ldap.Conn) error {
+	if config.Password == "" {
+		return connection.UnauthenticatedBind(config.Username)
+	}
+
+	return connection.Bind(config.Username, config.Password)
+}
diff --git a/access/users.go b/access/users.go
--- a/access/users.go
+++ b/access/users.go
@@ -95,5 +95,11 @@ func MarshalPublicKey(pubkey ssh.PublicK
 func UsernameFromPubkey(pubkey string) string {
 	keysLock.Lock()
 	defer keysLock.Unlock()
-	return usernames[pubkey]
+	username := usernames[pubkey]
+
+	if username == "" {
+		username = searchLdapPubkey(pubkey)
+	}
+
+	return username
 }
diff --git a/authorized_keys/command.go b/authorized_keys/command.go
--- a/authorized_keys/command.go
+++ b/authorized_keys/command.go
@@ -14,7 +14,7 @@ type Command struct {
 }
 
 func (c *Command) Run(g *globals.Globals) error {
-	if err := access.Setup(g.ReposPath, g.AdminRepo); err != nil {
+	if err := access.Setup(g.ReposPath, g.AdminRepo, g.LdapConfig); err != nil {
 		return err
 	}
 	defer access.Teardown()
diff --git a/docs/docs/ldap.md b/docs/docs/ldap.md
new file mode 100644
--- /dev/null
+++ b/docs/docs/ldap.md
@@ -0,0 +1,26 @@
+# LDAP
+
+In order to enable LDAP to fetch usernames for a SSH public key you need to
+pass a json configuration via ```--ldap-config="/path/to/ldap.json".
+Local public keys in ```./keys``` have precedence.
+
+ * username: the bind user name to LDAP (optional).
+ * password: the password pf bind user (optional, if empty it uses anonymous bind).
+ * address: the full address and port to LDAP server.
+ * baseDN: the base DN of your user scope.
+ * filter: the filter to fetch a username. %s will be replaced by the requested
+   authorization key.
+ * attribute: the attribute name of the username.
+
+## config
+
+```
+{
+        "username": "MyLdapBindUser",
+        "password": "",
+        "address": "ldap://server.local:389",
+        "baseDN": "OU=Developer,DC=Domain,DC=local",
+        "filter": "(sshPublicKeys=%s*)",
+        "attribute": "sAMAccountName"
+}
+```
diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml
--- a/docs/mkdocs.yml
+++ b/docs/mkdocs.yml
@@ -9,6 +9,7 @@ nav:
       - SSH Access: sshaccess.md
       - Access Control: accesscontrol.md
       - Command Reference: commandreference.md
+      - LDAP: ldap.md
   - Running:
       - 'Run Modes': runmodes.md
       - 'On Demand': ondemand.md
diff --git a/globals/globals.go b/globals/globals.go
--- a/globals/globals.go
+++ b/globals/globals.go
@@ -4,4 +4,5 @@ type Globals struct {
 	HgExecutable string `kong:"flag,name='hg-exe',env='HGK_HG_EXE',default='hg',help='The hg executable to use. This can be a relative or absolute path.'"`
 	ReposPath    string `kong:"flag,name='repos-path',env='HGK_REPOS_PATH',default='repos',help='the directory where the repositories are stored'"`
 	AdminRepo    string `kong:"flag,name='admin-repo',env='HGK_ADMIN_REPO',default='hgkeeper',help='the name of the admin repo to create/use'"`
+	LdapConfig   string `kong:"flag,name='ldap-config',env='HGK_LDAP_CONFIG',default='',help='the path to ldap config file'"`
 }
diff --git a/go.mod b/go.mod
--- a/go.mod
+++ b/go.mod
@@ -12,9 +12,12 @@ require (
 )
 
 require (
+	github.com/Azure/go-ntlmssp v0.0.0-20220621081337-cb9428e4ac1e // indirect
 	github.com/Knetic/govaluate v3.0.1-0.20171022003610-9aa49832a739+incompatible // indirect
 	github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect
 	github.com/davecgh/go-spew v1.1.1 // indirect
+	github.com/go-asn1-ber/asn1-ber v1.5.4 // indirect
+	github.com/go-ldap/ldap/v3 v3.4.4 // indirect
 	github.com/pmezard/go-difflib v1.0.0 // indirect
 	go.uber.org/atomic v1.10.0 // indirect
 	go.uber.org/multierr v1.11.0 // indirect
diff --git a/go.sum b/go.sum
--- a/go.sum
+++ b/go.sum
@@ -1,3 +1,5 @@
+github.com/Azure/go-ntlmssp v0.0.0-20220621081337-cb9428e4ac1e h1:NeAW1fUYUEWhft7pkxDf6WoUvEZJ/uOKsvtpjLnn8MU=
+github.com/Azure/go-ntlmssp v0.0.0-20220621081337-cb9428e4ac1e/go.mod h1:chxPXzSsl7ZWRAuOIE23GDNzjWuZquvFlgA8xmpunjU=
 github.com/Knetic/govaluate v3.0.1-0.20171022003610-9aa49832a739+incompatible h1:1G1pk05UrOh0NlF1oeaaix1x8XzrfjIDK47TY0Zehcw=
 github.com/Knetic/govaluate v3.0.1-0.20171022003610-9aa49832a739+incompatible/go.mod h1:r7JcOSlj0wfOMncg0iLm8Leh48TZaKVeNIfJntJ2wa0=
 github.com/alecthomas/assert/v2 v2.1.0 h1:tbredtNcQnoSd3QBhQWI7QZ3XHOVkw1Moklp2ojoH/0=
@@ -13,6 +15,10 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9
 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/gliderlabs/ssh v0.3.5 h1:OcaySEmAQJgyYcArR+gGGTHCyE7nvhEMTlYY+Dp8CpY=
 github.com/gliderlabs/ssh v0.3.5/go.mod h1:8XB4KraRrX39qHhT6yxPsHedjA08I/uBVwj4xC+/+z4=
+github.com/go-asn1-ber/asn1-ber v1.5.4 h1:vXT6d/FNDiELJnLb6hGNa309LMsrCoYFvpwHDF0+Y1A=
+github.com/go-asn1-ber/asn1-ber v1.5.4/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
+github.com/go-ldap/ldap/v3 v3.4.4 h1:qPjipEpt+qDa6SI/h1fzuGWoRUY+qqQ9sOZq67/PYUs=
+github.com/go-ldap/ldap/v3 v3.4.4/go.mod h1:fe1MsuN5eJJ1FeLT/LEBVdWfNWKh459R7aXgXtJC+aI=
 github.com/golang/mock v1.4.4 h1:l75CXGRSwbaYNpl/Z2X1XIIAMSCquvXgpVZDhwEIJsc=
 github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4=
 github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
@@ -26,6 +32,7 @@ github.com/stretchr/testify v1.3.0/go.mo
 github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
 github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
 github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals=
 github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk=
 github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
 go.uber.org/atomic v1.7.0 h1:ADUqmZGgLDDfbSL9ZmPxKTybcoEYHgpYfELNoN+7hsw=
@@ -39,6 +46,7 @@ go.uber.org/multierr v1.11.0/go.mod h1:2
 go.uber.org/zap v1.24.0 h1:FiJd5l1UOLj0wCgbSE0rwwXHzEdAZS6hiiSnxJN/D60=
 go.uber.org/zap v1.24.0/go.mod h1:2kMP+WWQ8aoFoedH3T2sq6iJ2yDWpHbP0f6MQbS9Gkg=
 golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
+golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
 golang.org/x/crypto v0.0.0-20220826181053-bd7e27e6170d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
 golang.org/x/crypto v0.7.0 h1:AvwMYaRytfdeVt3u6mLaxYtErKYjxA2OXjJ1HHq6t3A=
 golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU=
diff --git a/once/command.go b/once/command.go
--- a/once/command.go
+++ b/once/command.go
@@ -40,7 +40,7 @@ func (c *Command) init(repoPath, repoNam
 }
 
 func (c *Command) Run(g *globals.Globals) error {
-	if err := access.Setup(g.ReposPath, g.AdminRepo); err != nil {
+	if err := access.Setup(g.ReposPath, g.AdminRepo, g.LdapConfig); err != nil {
 		return err
 	}
 	defer access.Teardown()
diff --git a/serve/command.go b/serve/command.go
--- a/serve/command.go
+++ b/serve/command.go
@@ -29,7 +29,7 @@ func (c *Command) Run(g *globals.Globals
 		return fmt.Errorf("both HGWeb and SSH servers have been disabled")
 	}
 
-	if err := access.Setup(g.ReposPath, g.AdminRepo); err != nil {
+	if err := access.Setup(g.ReposPath, g.AdminRepo, g.LdapConfig); err != nil {
 		return err
 	}
 	defer access.Teardown()
