Files
llmgw/internal/router/directory.go
T
Ilker ManapandClaude Opus 5.5 ac25a9136a Sign-in is set up from the console, and a directory tried before it is kept
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>
2026-10-19 12:55:27 +02:00

179 lines
5.5 KiB
Go

package router
import (
"cmp"
"context"
"errors"
"fmt"
"log/slog"
"sync"
"time"
"gitea.mynodes.xyz/ilker/llmgw/internal/account"
"gitea.mynodes.xyz/ilker/llmgw/internal/config"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/adminapi"
"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
mu sync.Mutex
last *adminapi.DirectorySyncView
}
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()
last := &adminapi.DirectorySyncView{At: time.Now()}
defer func() {
d.mu.Lock()
d.last = last
d.mu.Unlock()
}()
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)
last.Error = "could not read the directory: " + err.Error()
return
}
last.People = len(people)
rep, err := d.keeper.Sync(ctx, people)
if err != nil {
d.log.Error("the directory sync stopped", "err", err, "people", len(people))
last.Error = err.Error()
return
}
last.Accounts, last.Suspended, last.Reinstated, last.RolesChanged = rep.Seen, rep.Suspended, rep.Reinstated, rep.Updated
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)
}
}
// lastSync is how the leader's last read of the directory went; nil before
// the first, and on a standby.
func (d *ldapDirectory) lastSync() *adminapi.DirectorySyncView {
d.mu.Lock()
defer d.mu.Unlock()
return d.last
}
// AuthStatus is how people sign in on this gateway, for the admin API.
func (r *Router) AuthStatus() adminapi.AuthView {
v := adminapi.AuthView{SCIM: r.scim.TokenFile != "", BaseURL: r.mail.BaseURL}
if r.accounts != nil {
v.SignupOpen = r.accounts.SignupOpen()
}
if o := r.oidc; o.Enabled() {
v.OIDC = &adminapi.OIDCView{Issuer: o.Issuer, ClientID: o.ClientID, Label: o.Label,
CreateAccounts: o.CreateAccounts, NoPasswords: o.NoPasswords}
}
if l := r.ldap; l.Enabled() {
v.LDAP = &adminapi.LDAPView{URLs: l.URLs, BaseDN: l.BaseDN, BindDN: l.BindDN, Label: l.Label, Off: r.ldapOff}
if r.directory != nil {
v.LDAP.LastSync = r.directory.lastSync()
}
}
return v
}
// TestDirectory tries an auth.ldap block, given as YAML, before it is kept:
// checked as the settings would be, then each step against the directory.
func (r *Router) TestDirectory(ctx context.Context, settings, password string) ([]adminapi.CheckView, error) {
l, err := config.ParseLDAP([]byte(settings))
if err != nil {
return nil, err
}
dir, err := directory.NewLDAP(l)
if err != nil {
return []adminapi.CheckView{{Step: "certificate", Detail: err.Error()}}, nil
}
if password != "" {
dir.WithPassword(password)
}
ctx, cancel := context.WithTimeout(ctx, time.Minute)
defer cancel()
checks := dir.Check(ctx, directory.Mapping{Roles: l.Roles, Memberships: l.Memberships})
out := make([]adminapi.CheckView, len(checks))
for i, c := range checks {
out[i] = adminapi.CheckView(c)
}
return out, nil
}