Files
llmgw/internal/config/ldap.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

138 lines
5.3 KiB
Go

package config
import (
"errors"
"fmt"
"net/url"
"strings"
"time"
)
// LDAP is sign-in against a directory — Active Directory above all — with
// the directory's own passwords, and the accounts kept in step with it.
//
// auth:
// ldap:
// urls: [ldaps://dc1.corp.example.com, ldaps://dc2.corp.example.com]
// bind_dn: CN=llmgw,OU=Service Accounts,DC=corp,DC=example,DC=com
// bind_password_file: /etc/llmgw/secrets/ldap-bind
// base_dn: OU=Staff,DC=corp,DC=example,DC=com
// create_accounts: true
// roles: {admin: [llmgw-admins]}
// memberships: {ml-team: "team:Acme/ml"}
//
// A person signs in on the portal's form with their sAMAccountName, their
// userPrincipalName or their mail, and their directory password. The
// gateway finds them with the bind account, checks the password by binding
// as them, and reads their groups, nested ones included. Every sync_interval
// the leader reads the directory again: an account disabled or gone there
// is suspended here, its sessions ended and its API keys refused.
type LDAP struct {
// URLs are the directory's servers, tried in turn: ldaps:// (636), or
// ldap:// (389) with StartTLS.
URLs []string `koanf:"urls"`
// StartTLS upgrades an ldap:// connection before anything is sent. A
// password never crosses a connection without TLS: ldap:// without it
// is refused.
StartTLS bool `koanf:"start_tls"`
// CAFile is the authority the servers' certificates are checked against,
// when it is not the system's: an AD Certificate Services root.
CAFile string `koanf:"ca_file"`
// BindDN and the file holding its password: a service account that may
// read users and groups. Nothing is written to the directory.
BindDN string `koanf:"bind_dn"`
BindPasswordFile string `koanf:"bind_password_file"`
// BaseDN is where people are searched for, subtree included.
BaseDN string `koanf:"base_dn"`
// GroupBaseDN is where their groups are; empty is BaseDN's domain root.
GroupBaseDN string `koanf:"group_base_dn"`
// UserFilter narrows who may sign in, ANDed with the login match: a
// group (memberOf=...) or an OU's people only. Empty is every enabled
// user object.
UserFilter string `koanf:"user_filter"`
// Label is what the form says to sign in with: "Sign in with your
// <label> account". Empty is "Active Directory".
Label string `koanf:"label"`
// CreateAccounts makes an account, active, for a person the directory
// vouches for and the gateway does not know. Off, only existing accounts
// sign in this way.
CreateAccounts bool `koanf:"create_accounts"`
// Roles maps a staff role (viewer, operator, billing, admin) to the
// groups that give it, by name (CN) or whole DN. Set at every sign-in
// and sync: someone taken out of the group loses the role.
Roles map[string][]string `koanf:"roles"`
// Memberships maps a group, by CN or DN, to a place in the tree —
// org:NAME, team:ORG/TEAM, project:ORG/TEAM/PROJECT — joined at sign-in
// and kept by the sync, which also takes away a place whose group the
// person has left.
Memberships map[string]string `koanf:"memberships"`
// SyncInterval is how often the leader reads the directory. Zero is
// 15m; negative turns the sync off, leaving only sign-in.
SyncInterval time.Duration `koanf:"sync_interval"`
// Timeout bounds each round trip to a server. Zero is 10s.
Timeout time.Duration `koanf:"timeout"`
}
// Enabled reports whether directory sign-in is configured.
func (l LDAP) Enabled() bool { return len(l.URLs) > 0 }
// Validate says what in the configuration cannot work.
func (l LDAP) Validate() error {
if !l.Enabled() {
return nil
}
for _, raw := range l.URLs {
u, err := url.Parse(raw)
if err != nil || u.Host == "" {
return fmt.Errorf("auth.ldap.urls: %q is not an ldap:// or ldaps:// address", raw)
}
switch u.Scheme {
case "ldaps":
case "ldap":
if !l.StartTLS {
return fmt.Errorf("auth.ldap.urls: %q is plaintext: use ldaps://, or set start_tls; passwords would cross it", raw)
}
default:
return fmt.Errorf("auth.ldap.urls: %q is not an ldap:// or ldaps:// address", raw)
}
}
switch {
case strings.TrimSpace(l.BindDN) == "" || l.BindPasswordFile == "":
return errors.New("auth.ldap: bind_dn and bind_password_file are needed, for the account that finds people")
case strings.TrimSpace(l.BaseDN) == "":
return errors.New("auth.ldap: base_dn is needed, where people are searched for")
case l.UserFilter != "" && !(strings.HasPrefix(l.UserFilter, "(") && strings.HasSuffix(l.UserFilter, ")")):
return fmt.Errorf("auth.ldap.user_filter: %q is not an LDAP filter in parentheses", l.UserFilter)
}
for role := range l.Roles {
if !validStaffRole(role) {
return fmt.Errorf("auth.ldap.roles: %q is not viewer, operator, billing or admin", role)
}
}
for g, place := range l.Memberships {
if !validPlace(place) {
return fmt.Errorf("auth.ldap.memberships[%s]: %q is not org:NAME, team:ORG/TEAM or project:ORG/TEAM/PROJECT", g, place)
}
}
return nil
}
// Interval is SyncInterval with its default; zero means off.
func (l LDAP) Interval() time.Duration {
switch {
case l.SyncInterval < 0:
return 0
case l.SyncInterval == 0:
return 15 * time.Minute
}
return l.SyncInterval
}
// RoundTrip is Timeout with its default.
func (l LDAP) RoundTrip() time.Duration {
if l.Timeout <= 0 {
return 10 * time.Second
}
return l.Timeout
}