Files
llmgw/internal/router/directory.go
T
Ilker ManapandClaude Opus 5.5 f27d722111 People sign in with their Active Directory account
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>
2026-10-18 10:59:50 +02:00

113 lines
3.4 KiB
Go

package router
import (
"cmp"
"context"
"errors"
"fmt"
"log/slog"
"time"
"gitea.mynodes.xyz/ilker/llmgw/internal/account"
"gitea.mynodes.xyz/ilker/llmgw/internal/config"
"gitea.mynodes.xyz/ilker/llmgw/internal/directory"
storepkg "gitea.mynodes.xyz/ilker/llmgw/internal/store"
)
// Sign-in against a directory (auth.ldap), and the accounts kept in step
// with it by the leader.
// ldapDirectory is auth.ldap as the gateway uses it.
type ldapDirectory struct {
dir *directory.LDAP
keeper *directory.Keeper
every time.Duration
label string
log *slog.Logger
}
func newLDAPDirectory(cfg config.LDAP, st *storepkg.DB, log *slog.Logger) (*ldapDirectory, error) {
dir, err := directory.NewLDAP(cfg)
if err != nil {
return nil, err
}
return &ldapDirectory{
dir: dir,
keeper: &directory.Keeper{St: st, Name: "ldap", CreateAccounts: cfg.CreateAccounts,
Mapping: directory.Mapping{Roles: cfg.Roles, Memberships: cfg.Memberships}},
every: cfg.Interval(),
label: cmp.Or(cfg.Label, "Active Directory"),
log: log,
}, nil
}
// SignIn is account.Directory: the person's name and password checked by
// the directory, then their account made or kept in step.
func (d *ldapDirectory) SignIn(ctx context.Context, login, password string) (storepkg.User, error) {
id, err := d.dir.Authenticate(ctx, login, password)
switch {
case errors.Is(err, directory.ErrBadCredentials):
return storepkg.User{}, account.ErrBadCredentials
case err != nil:
d.log.Warn("the directory could not be asked about a sign-in", "err", err)
return storepkg.User{}, fmt.Errorf("the directory could not be asked: %w", err)
}
u, err := d.keeper.SignIn(ctx, id)
switch {
case errors.Is(err, directory.ErrSuspended):
return storepkg.User{}, account.ErrSuspended
case errors.Is(err, directory.ErrNoAccount), errors.Is(err, directory.ErrNoEmail):
// Said to the log, not the form: the form says what a wrong
// password says.
d.log.Info("the directory vouched for a person who has no account here", "dn", id.DN, "err", err)
return storepkg.User{}, account.ErrBadCredentials
case errors.Is(err, directory.ErrNotActive):
return storepkg.User{}, account.ErrNotApproved
case err != nil:
return storepkg.User{}, err
}
return u, nil
}
// sync reads the directory every interval, on the leader, and brings the
// accounts it keeps in step.
func (d *ldapDirectory) sync(ctx context.Context, leader func() bool) {
if d.every <= 0 {
return
}
t := time.NewTicker(d.every)
defer t.Stop()
for {
if leader() {
d.syncOnce(ctx)
}
select {
case <-ctx.Done():
return
case <-t.C:
}
}
}
func (d *ldapDirectory) syncOnce(ctx context.Context) {
ctx, cancel := context.WithTimeout(ctx, 5*time.Minute)
defer cancel()
people, err := d.dir.People(ctx, d.keeper.Mapping)
if err != nil {
d.log.Warn("could not read the directory; accounts left as they are", "err", err)
return
}
rep, err := d.keeper.Sync(ctx, people)
if err != nil {
d.log.Error("the directory sync stopped", "err", err, "people", len(people))
return
}
for _, where := range rep.Unmapped {
d.log.Warn("auth.ldap.memberships maps a group to a place the tree does not have", "place", where)
}
if rep.Suspended+rep.Reinstated+rep.Updated > 0 {
d.log.Info("accounts kept in step with the directory", "people", len(people), "accounts", rep.Seen,
"suspended", rep.Suspended, "reinstated", rep.Reinstated, "roles_changed", rep.Updated)
}
}