People → Sign-in shows single sign-on, Active Directory and SCIM, and the directory's last sync. E sets up Entra ID from the tenant and client IDs; D sets up Active Directory, tried from the control plane first: each server bound, people who may sign in, members of each mapped group. The secrets go into the store as store:auth/..., sealed by the master key, and the CA into the settings, so no control plane needs a file. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
163 lines
5.9 KiB
Go
163 lines
5.9 KiB
Go
package directory
|
|
|
|
import (
|
|
"errors"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/go-ldap/ldap/v3"
|
|
|
|
"gitea.mynodes.xyz/ilker/llmgw/internal/config"
|
|
)
|
|
|
|
// fakeConn is a directory with one person in it.
|
|
type fakeConn struct {
|
|
binds []string
|
|
filters []string
|
|
password string // the person's
|
|
people int // how many the person search finds
|
|
refused bool // the service account's password is wrong
|
|
}
|
|
|
|
func (f *fakeConn) Bind(user, password string) error {
|
|
f.binds = append(f.binds, user)
|
|
if f.refused && strings.HasPrefix(user, "CN=llmgw") {
|
|
return ldap.NewError(ldap.LDAPResultInvalidCredentials, errors.New("invalid credentials"))
|
|
}
|
|
if strings.HasPrefix(user, "CN=Ada") && password != f.password {
|
|
return ldap.NewError(ldap.LDAPResultInvalidCredentials, errors.New("invalid credentials"))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (f *fakeConn) Search(r *ldap.SearchRequest) (*ldap.SearchResult, error) {
|
|
f.filters = append(f.filters, r.Filter)
|
|
var out []*ldap.Entry
|
|
for i := range f.people {
|
|
e := ldap.NewEntry("CN=Ada,OU=Staff,DC=corp,DC=example", map[string][]string{
|
|
"mail": {"ada@corp.example"}, "displayName": {"Ada"}, "userAccountControl": {"512"},
|
|
})
|
|
e.Attributes = append(e.Attributes, &ldap.EntryAttribute{Name: "objectGUID", ByteValues: [][]byte{guidBytes(byte(i))}})
|
|
out = append(out, e)
|
|
}
|
|
return &ldap.SearchResult{Entries: out}, nil
|
|
}
|
|
|
|
func (f *fakeConn) SearchWithPaging(r *ldap.SearchRequest, _ uint32) (*ldap.SearchResult, error) {
|
|
f.filters = append(f.filters, r.Filter)
|
|
return &ldap.SearchResult{Entries: []*ldap.Entry{
|
|
ldap.NewEntry("CN=ml-team,OU=Groups,DC=corp,DC=example", map[string][]string{"cn": {"ml-team"}}),
|
|
}}, nil
|
|
}
|
|
|
|
func (f *fakeConn) Close() error { return nil }
|
|
|
|
func guidBytes(last byte) []byte {
|
|
return []byte{0x78, 0x56, 0x34, 0x12, 0x34, 0x12, 0x78, 0x56, 0x9a, 0xbc, 0xde, 0xf0, 0x12, 0x34, 0x56, last}
|
|
}
|
|
|
|
func fakeLDAP(t *testing.T, c *fakeConn) *LDAP {
|
|
t.Helper()
|
|
pw := filepath.Join(t.TempDir(), "bind")
|
|
_ = os.WriteFile(pw, []byte("svc-secret\n"), 0o600)
|
|
d, err := NewLDAP(config.LDAP{URLs: []string{"ldaps://dc1"}, BindDN: "CN=llmgw,OU=Svc,DC=corp,DC=example",
|
|
BindPasswordFile: pw, BaseDN: "OU=Staff,DC=corp,DC=example", UserFilter: "(memberOf=CN=llm,DC=corp,DC=example)"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
d.dial = func(string) (ldapConn, error) { return c, nil }
|
|
return d
|
|
}
|
|
|
|
func TestAPersonIsCheckedByBindingAsThemAndTheirGroupsReadNested(t *testing.T) {
|
|
c := &fakeConn{password: "ad-secret", people: 1}
|
|
d := fakeLDAP(t, c)
|
|
id, err := d.Authenticate(t.Context(), `CORP\ada*)(x=`, "ad-secret")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if id.Email != "ada@corp.example" || id.ID != "12345678-1234-5678-9abc-def012345600" ||
|
|
!slicesHas(id.Groups, "ml-team") || !slicesHas(id.Groups, "CN=ml-team,OU=Groups,DC=corp,DC=example") {
|
|
t.Errorf("identity %+v", id)
|
|
}
|
|
// The name is escaped into the filter, the domain part dropped; the
|
|
// user filter is ANDed in; disabled accounts are not found; groups are
|
|
// read with the in-chain rule.
|
|
find, groups := c.filters[0], c.filters[1]
|
|
if !strings.Contains(find, `(sAMAccountName=ada\2a\29\28x=)`) || !strings.Contains(find, "(memberOf=CN=llm,DC=corp,DC=example)") ||
|
|
!strings.Contains(find, "1.2.840.113556.1.4.803:=2") || !strings.Contains(groups, inChain) {
|
|
t.Errorf("filters %q", c.filters)
|
|
}
|
|
// Service account, then the person, then the service account again.
|
|
if len(c.binds) != 3 || c.binds[1] != "CN=Ada,OU=Staff,DC=corp,DC=example" {
|
|
t.Errorf("binds %v", c.binds)
|
|
}
|
|
}
|
|
|
|
func TestWrongPasswordsEmptyOnesAndAmbiguousNamesAreRefusedAlike(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
people int
|
|
password string
|
|
}{{1, "wrong"}, {1, ""}, {0, "ad-secret"}, {2, "ad-secret"}} {
|
|
d := fakeLDAP(t, &fakeConn{password: "ad-secret", people: tc.people})
|
|
if _, err := d.Authenticate(t.Context(), "ada", tc.password); !errors.Is(err, ErrBadCredentials) {
|
|
t.Errorf("%d found, password %q: %v", tc.people, tc.password, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestPlaintextLDAPIsRefusedWithoutStartTLS(t *testing.T) {
|
|
base := config.LDAP{BindDN: "CN=x", BindPasswordFile: "/f", BaseDN: "DC=corp"}
|
|
for _, c := range []struct {
|
|
urls []string
|
|
start bool
|
|
ok bool
|
|
}{{[]string{"ldaps://dc1"}, false, true}, {[]string{"ldap://dc1"}, false, false},
|
|
{[]string{"ldap://dc1"}, true, true}, {[]string{"http://dc1"}, true, false}} {
|
|
l := base
|
|
l.URLs, l.StartTLS = c.urls, c.start
|
|
if err := l.Validate(); (err == nil) != c.ok {
|
|
t.Errorf("%v start_tls=%v: %v", c.urls, c.start, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func slicesHas(xs []string, x string) bool {
|
|
for _, v := range xs {
|
|
if v == x {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func TestADirectoryIsTriedStepByStepBeforeItsSettingsAreKept(t *testing.T) {
|
|
m := Mapping{Roles: map[string][]string{"admin": {"llmgw-admins"}}}
|
|
checks := fakeLDAP(t, &fakeConn{people: 1}).WithPassword("typed-in").Check(t.Context(), m)
|
|
if len(checks) != 3 || !checks[0].OK || checks[0].Step != "ldaps://dc1" || !checks[1].OK ||
|
|
checks[2].Step != "group llmgw-admins" || !checks[2].OK {
|
|
t.Errorf("checks %+v", checks)
|
|
}
|
|
// A refused service account stops at the server, and says why.
|
|
checks = fakeLDAP(t, &fakeConn{refused: true}).Check(t.Context(), m)
|
|
if len(checks) != 1 || checks[0].OK || !strings.Contains(checks[0].Detail, "Invalid Credentials") {
|
|
t.Errorf("refused: %+v", checks)
|
|
}
|
|
}
|
|
|
|
func TestACertificateGoCannotReadIsSaidNotSkipped(t *testing.T) {
|
|
if _, err := certPool([]byte("not a certificate")); err == nil {
|
|
t.Error("an empty pool was taken")
|
|
}
|
|
// A CERTIFICATE block Go cannot parse, as it cannot Samba's own (its
|
|
// serial is negative): an error, not an empty pool.
|
|
neg := "-----BEGIN CERTIFICATE-----\nMIIBNTCB3KADAgECAgT/////MAoGCCqGSM49BAMCMA8xDTALBgNVBAMTBHRlc3Qw\n" +
|
|
"HhcNMjYwMTAxMDAwMDAwWhcNMjgwMTAxMDAwMDAwWjAPMQ0wCwYDVQQDEwR0ZXN0\n" +
|
|
"MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE\n-----END CERTIFICATE-----\n"
|
|
if _, err := certPool([]byte(neg)); err == nil {
|
|
t.Error("a broken certificate made a pool")
|
|
}
|
|
}
|