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

161 lines
6.2 KiB
Go

package config
import (
"errors"
"fmt"
"net/url"
"strings"
"time"
"github.com/knadh/koanf/parsers/yaml"
"github.com/knadh/koanf/v2"
)
// 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"`
// CA is the same authority written out, PEM: kept in the cluster's
// settings, so no file has to be put on every control plane. Used in
// place of CAFile when both are set.
CA string `koanf:"ca"`
// 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 l.CA != "" && !strings.Contains(l.CA, "-----BEGIN CERTIFICATE-----"):
return errors.New("auth.ldap.ca: not a PEM certificate")
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
}
// ParseLDAP is an auth.ldap block, as YAML, read on its own as the cluster's
// settings would read it, and checked.
func ParseLDAP(block []byte) (LDAP, error) {
var l LDAP
k := koanf.New(".")
if err := k.Load(bytesProvider(block), yaml.Parser()); err != nil {
return l, fmt.Errorf("auth.ldap: %w", err)
}
if err := k.UnmarshalWithConf("", &l, koanf.UnmarshalConf{Tag: "koanf"}); err != nil {
return l, fmt.Errorf("auth.ldap: %w", err)
}
return l, l.Validate()
}