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>
161 lines
6.2 KiB
Go
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()
|
|
}
|