auth.ldap checks a username and password against the domain controllers over LDAPS or StartTLS: found with a service account, checked by binding as the person. Groups, nested ones included, give staff roles and places in the tree. Accounts are tied to the objectGUID; the leader reads the directory every 15 minutes, suspends whom it disabled or removed and reinstates them after, and refuses a sync that would suspend most. Entra ID sends no groups for someone in more than 200, only where to fetch them; that was read as none and took an administrator's role away. It is now left as it was, and logged. An account made by SCIM or OIDC can be reinstated again. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
143 lines
4.7 KiB
Go
143 lines
4.7 KiB
Go
package directory
|
|
|
|
import (
|
|
"errors"
|
|
"path/filepath"
|
|
"slices"
|
|
"testing"
|
|
"time"
|
|
|
|
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
|
|
)
|
|
|
|
func newKeeper(t *testing.T) (*Keeper, *store.DB, string) {
|
|
t.Helper()
|
|
st, err := store.Open(t.Context(), filepath.Join(t.TempDir(), "s.db"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { st.Close() })
|
|
org, _ := st.CreateOrg(t.Context(), "Acme")
|
|
team, _ := st.CreateTeam(t.Context(), org, "ml")
|
|
k := &Keeper{St: st, Name: "ldap", CreateAccounts: true, Mapping: Mapping{
|
|
Roles: map[string][]string{"admin": {"llmgw-admins"}, "viewer": {"CN=Staff,OU=Groups,DC=corp,DC=example"}},
|
|
Memberships: map[string]string{"ml-team": "team:Acme/ml", "ghosts": "team:Acme/nowhere"},
|
|
}}
|
|
return k, st, team
|
|
}
|
|
|
|
var ada = Identity{ID: "guid-ada", DN: "CN=Ada,OU=Staff,DC=corp,DC=example", Email: "Ada@Corp.example", Name: "Ada L",
|
|
Groups: []string{"ml-team", "CN=ml-team,OU=Groups,DC=corp,DC=example", "Staff", "CN=Staff,OU=Groups,DC=corp,DC=example"}}
|
|
|
|
func TestAPersonTheDirectoryVouchesForIsMadeAnAccountWithWhatTheirGroupsGive(t *testing.T) {
|
|
k, st, team := newKeeper(t)
|
|
u, err := k.SignIn(t.Context(), ada)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if u.Email != "ada@corp.example" || u.Name != "Ada L" || u.PasswordHash != "!ldap" || u.StaffRole() != "viewer" {
|
|
t.Errorf("account %+v role %q", u, u.StaffRole())
|
|
}
|
|
ms, _ := st.MembershipsOf(t.Context(), u.ID)
|
|
if len(ms) != 1 || ms[0].ScopeID != team || ms[0].AddedBy != "ldap" {
|
|
t.Errorf("memberships %+v", ms)
|
|
}
|
|
// Their mail changes there: still the same account, by its directory id.
|
|
moved := ada
|
|
moved.Email = "ada.lovelace@corp.example"
|
|
again, err := k.SignIn(t.Context(), moved)
|
|
if err != nil || again.ID != u.ID {
|
|
t.Errorf("a new mail made a new account: %v %+v", err, again)
|
|
}
|
|
// The higher role of two wins.
|
|
boss := ada
|
|
boss.Groups = append(slices.Clone(ada.Groups), "llmgw-admins")
|
|
if u, _ := k.SignIn(t.Context(), boss); u.StaffRole() != "admin" {
|
|
t.Errorf("role %q", u.StaffRole())
|
|
}
|
|
}
|
|
|
|
func TestWithoutAccountsMadeOnlyThoseThatExistSignIn(t *testing.T) {
|
|
k, _, _ := newKeeper(t)
|
|
k.CreateAccounts = false
|
|
if _, err := k.SignIn(t.Context(), ada); !errors.Is(err, ErrNoAccount) {
|
|
t.Errorf("err %v", err)
|
|
}
|
|
}
|
|
|
|
func TestTheSyncSuspendsWhomTheDirectoryDisabledAndReinstatesThemAfter(t *testing.T) {
|
|
k, st, _ := newKeeper(t)
|
|
ctx := t.Context()
|
|
people := []Identity{ada}
|
|
for _, n := range []string{"b", "c", "d"} {
|
|
people = append(people, Identity{ID: "guid-" + n, Email: n + "@corp.example"})
|
|
}
|
|
ids := map[string]string{}
|
|
for _, p := range people {
|
|
u, err := k.SignIn(ctx, p)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
ids[p.ID] = u.ID
|
|
}
|
|
if err := st.CreateSession(ctx, ids["guid-ada"], "tok", "csrf", time.Hour); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// An administrator suspends c here, by hand.
|
|
_ = st.SetUserState(ctx, ids["guid-c"], store.UserSuspended)
|
|
|
|
// Ada is disabled there, and has left ml-team.
|
|
off := ada
|
|
off.Disabled = true
|
|
rep, err := k.Sync(ctx, []Identity{off, people[1], people[2], people[3]})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
u, _ := st.UserByID(ctx, ids["guid-ada"])
|
|
if u.State != store.UserSuspended || rep.Suspended != 1 {
|
|
t.Errorf("ada %s, report %+v", u.State, rep)
|
|
}
|
|
if _, _, err := st.SessionByToken(ctx, "tok"); err == nil {
|
|
t.Error("a suspended account's session still works")
|
|
}
|
|
if !slices.Contains(rep.Unmapped, "team:Acme/nowhere") {
|
|
t.Errorf("an unmapped place was not said: %+v", rep)
|
|
}
|
|
|
|
// Enabled again, and out of ml-team: reinstated, and its place taken
|
|
// away. c, suspended by hand, stays so.
|
|
back := ada
|
|
back.Groups = []string{"Staff", "CN=Staff,OU=Groups,DC=corp,DC=example"}
|
|
rep, err = k.Sync(ctx, []Identity{back, people[1], people[2], people[3]})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
u, _ = st.UserByID(ctx, ids["guid-ada"])
|
|
ms, _ := st.MembershipsOf(ctx, u.ID)
|
|
if u.State != store.UserActive || rep.Reinstated != 1 || len(ms) != 0 {
|
|
t.Errorf("ada %s, memberships %+v, report %+v", u.State, ms, rep)
|
|
}
|
|
if c, _ := st.UserByID(ctx, ids["guid-c"]); c.State != store.UserSuspended {
|
|
t.Errorf("an account suspended by hand was reinstated: %s", c.State)
|
|
}
|
|
}
|
|
|
|
func TestASyncThatWouldSuspendMostIsRefused(t *testing.T) {
|
|
k, st, _ := newKeeper(t)
|
|
ctx := t.Context()
|
|
for _, n := range []string{"a", "b", "c", "d"} {
|
|
if _, err := k.SignIn(ctx, Identity{ID: "guid-" + n, Email: n + "@corp.example"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
for _, people := range [][]Identity{nil, {{ID: "guid-a", Email: "a@corp.example"}}} {
|
|
if _, err := k.Sync(ctx, people); !errors.Is(err, ErrImplausible) {
|
|
t.Errorf("%d people: %v", len(people), err)
|
|
}
|
|
}
|
|
users, _ := st.Users(ctx, store.UserSuspended)
|
|
if len(users) != 0 {
|
|
t.Errorf("suspended %d", len(users))
|
|
}
|
|
}
|