All checks were successful
Build and Publish Container Image / build-and-push (push) Successful in 41s
Closes gap G08. /healthz returned a constant without probing anything, the server called ListenAndServe with no signal handling, and the operational limits of in-memory state, startup-loaded keys and local-only logout lived in code comments rather than anywhere an operator would look. /readyz probes LLDAP, Authelia and privacyIDEA; /healthz stays liveness and probes nothing. Keeping them distinct matters: wiring liveness to dependency health means an orchestrator restarts KeyCape when a dependency blinks, and a restart also discards every in-flight login, so the reaction is worse than the condition it reacts to. LLDAP is probed with a bind rather than a dial, since a rotated or revoked service password leaves the port open and every lookup failing -- exactly what readiness should catch and exactly what a dial would miss. The response names the failing check but never the reason: the endpoint is unauthenticated and upstream error text carries hostnames and sometimes credentials-in-URLs. Results are cached for 2s so an unauthenticated endpoint cannot be used to drive unbounded upstream traffic, and probes run concurrently under a 3s bound so a hung dependency makes the endpoint answer rather than hang with it. SIGTERM and SIGINT now drain in-flight requests for 15s, under the 30s read/write timeouts so a stuck request cannot outlive the window before SIGKILL. docs/operations.md states the single-replica topology and why, and three limits easy to get wrong: the constant key-1 key ID makes same-kid rotation a trap for consumers caching JWKS, removing a client does not revoke its issued tokens, and /logout is local only. No throughput figures are given, since nothing here benchmarks KeyCape. Shared storage and refresh tokens stay excluded, as G08 allows. Verified in the running executable: 503 naming all three checks failed while /healthz returned 200, the LLDAP check flipping to ok once started, and 40/40 requests succeeding across a SIGTERM. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NV9oijZukGyGbRQGGKnK4P Assistant: claude-code Assistant-Model: opus Assistant-Process: 713576@bnt-lap001 Assistant-Session: 384c511d-9bce-4cb8-a676-2aef6c0c8df6
473 lines
14 KiB
Go
473 lines
14 KiB
Go
package lldap
|
|
|
|
import (
|
|
"context"
|
|
"crypto/tls"
|
|
"fmt"
|
|
"net/url"
|
|
"strings"
|
|
|
|
"github.com/go-ldap/ldap/v3"
|
|
|
|
"keycape/internal/domain"
|
|
"keycape/internal/validator"
|
|
)
|
|
|
|
// LDAPConn is a minimal interface over an LDAP connection, enabling test injection.
|
|
// Only the operations used by the adapter are included; no concrete LDAP types are
|
|
// exposed through return values or parameters visible outside this package.
|
|
type LDAPConn interface {
|
|
Bind(username, password string) error
|
|
Search(request *ldap.SearchRequest) (*ldap.SearchResult, error)
|
|
Close() error
|
|
}
|
|
|
|
// LDAPAdapter implements domain.UserRepository using an LLDAP backend.
|
|
// All LDAP types are confined to this package — the domain and server layers
|
|
// are not aware of any LDAP-specific constructs.
|
|
type LDAPAdapter struct {
|
|
cfg Config
|
|
dialFn func(addr string) (LDAPConn, error)
|
|
}
|
|
|
|
// New returns a production-ready LDAPAdapter that dials real LDAP connections.
|
|
func New(cfg Config) *LDAPAdapter {
|
|
return &LDAPAdapter{
|
|
cfg: cfg,
|
|
dialFn: defaultDialFn(cfg),
|
|
}
|
|
}
|
|
|
|
// NewForTest returns an LDAPAdapter with a custom dial function for test injection.
|
|
// Production code should use New instead.
|
|
func NewForTest(cfg Config, dialFn func(addr string) (LDAPConn, error)) *LDAPAdapter {
|
|
return &LDAPAdapter{cfg: cfg, dialFn: dialFn}
|
|
}
|
|
|
|
// defaultDialFn returns a dial function that establishes a real LDAP connection.
|
|
func defaultDialFn(cfg Config) func(addr string) (LDAPConn, error) {
|
|
return func(addr string) (LDAPConn, error) {
|
|
u, err := url.Parse(cfg.URL)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("lldap: invalid URL %q: %w", cfg.URL, err)
|
|
}
|
|
if u.Scheme == "ldaps" {
|
|
conn, err := ldap.DialTLS("tcp", addr, &tls.Config{
|
|
InsecureSkipVerify: cfg.TLSSkipVerify, //nolint:gosec // dev flag, documented
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("lldap: TLS dial %q: %w", addr, err)
|
|
}
|
|
return conn, nil
|
|
}
|
|
conn, err := ldap.Dial("tcp", addr)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("lldap: dial %q: %w", addr, err)
|
|
}
|
|
return conn, nil
|
|
}
|
|
}
|
|
|
|
// dial opens a new LDAP connection and performs the service-account bind.
|
|
func (a *LDAPAdapter) dial() (LDAPConn, error) {
|
|
u, err := url.Parse(a.cfg.URL)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("lldap: invalid URL %q: %w", a.cfg.URL, err)
|
|
}
|
|
host := u.Host
|
|
if host == "" {
|
|
host = a.cfg.URL // fallback for bare addr passed in tests
|
|
}
|
|
conn, err := a.dialFn(host)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := conn.Bind(a.cfg.BindDN, a.cfg.BindPW); err != nil {
|
|
_ = conn.Close()
|
|
return nil, fmt.Errorf("lldap: service bind failed: %w", err)
|
|
}
|
|
return conn, nil
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// domain.UserRepository implementation
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// LookupUser retrieves the canonical User for the given username.
|
|
// Returns domain.ErrUserNotFound when no matching entry exists.
|
|
// After mapping LDAP attributes the result is run through the canonical
|
|
// LDAP schema validator; a validation failure is returned as an error.
|
|
func (a *LDAPAdapter) LookupUser(ctx context.Context, username string) (*domain.User, error) {
|
|
conn, err := a.dial()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer conn.Close()
|
|
|
|
filter := fmt.Sprintf("(uid=%s)", ldap.EscapeFilter(username))
|
|
req := ldap.NewSearchRequest(
|
|
a.cfg.userBaseDN(),
|
|
ldap.ScopeWholeSubtree,
|
|
ldap.NeverDerefAliases,
|
|
0, 0, false,
|
|
filter,
|
|
[]string{"dn", "uid", "cn", "sn", "mail", "memberOf"},
|
|
nil,
|
|
)
|
|
result, err := conn.Search(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("lldap: search for user %q: %w", username, err)
|
|
}
|
|
if len(result.Entries) == 0 {
|
|
return nil, domain.ErrUserNotFound
|
|
}
|
|
|
|
entry := result.Entries[0]
|
|
user := mapEntryToUser(entry)
|
|
|
|
// Runtime login should not fail because a live directory entry is missing
|
|
// provisioning metadata such as cn/sn. Keep the warning visible for
|
|
// diagnostics, but return the resolved user so token issuance can proceed.
|
|
snap := validator.Snapshot{Users: []domain.User{user}}
|
|
report := validator.Validate(snap, validator.ModeProvisioning)
|
|
if !report.Passed {
|
|
if user.LDAPAttributes == nil {
|
|
user.LDAPAttributes = make(map[string]string)
|
|
}
|
|
user.LDAPAttributes["_validation_warning"] = validationSummary(report)
|
|
}
|
|
|
|
return &user, nil
|
|
}
|
|
|
|
// LookupGroups retrieves all groups the user (identified by their LDAP DN) belongs to.
|
|
func (a *LDAPAdapter) LookupGroups(ctx context.Context, userDN string) ([]domain.Group, error) {
|
|
conn, err := a.dial()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer conn.Close()
|
|
|
|
// Search for groups that list the user as a member.
|
|
filter := fmt.Sprintf("(member=%s)", ldap.EscapeFilter(userDN))
|
|
req := ldap.NewSearchRequest(
|
|
a.cfg.groupBaseDN(),
|
|
ldap.ScopeWholeSubtree,
|
|
ldap.NeverDerefAliases,
|
|
0, 0, false,
|
|
filter,
|
|
[]string{"dn", "cn", "description"},
|
|
nil,
|
|
)
|
|
result, err := conn.Search(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("lldap: group search for DN %q: %w", userDN, err)
|
|
}
|
|
|
|
groups := make([]domain.Group, 0, len(result.Entries))
|
|
for _, entry := range result.Entries {
|
|
groups = append(groups, domain.Group{
|
|
ID: entry.DN,
|
|
Name: entry.GetAttributeValue("cn"),
|
|
Description: entry.GetAttributeValue("description"),
|
|
})
|
|
}
|
|
return groups, nil
|
|
}
|
|
|
|
// ListGroups returns every group in the LLDAP group subtree with its members
|
|
// populated, independently of any user's membership. Export tooling needs this
|
|
// to enumerate empty and unreferenced groups, which a (member=DN) search by
|
|
// construction cannot see.
|
|
func (a *LDAPAdapter) ListGroups(ctx context.Context) ([]domain.Group, error) {
|
|
conn, err := a.dial()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer conn.Close()
|
|
|
|
req := ldap.NewSearchRequest(
|
|
a.cfg.groupBaseDN(),
|
|
ldap.ScopeWholeSubtree,
|
|
ldap.NeverDerefAliases,
|
|
0, 0, false,
|
|
"(|(objectClass=groupOfNames)(objectClass=groupOfUniqueNames))",
|
|
[]string{"dn", "cn", "description", "member", "uniqueMember"},
|
|
nil,
|
|
)
|
|
result, err := conn.Search(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("lldap: list groups search: %w", err)
|
|
}
|
|
|
|
groups := make([]domain.Group, 0, len(result.Entries))
|
|
for _, entry := range result.Entries {
|
|
members := entry.GetAttributeValues("member")
|
|
if len(members) == 0 {
|
|
members = entry.GetAttributeValues("uniqueMember")
|
|
}
|
|
groups = append(groups, domain.Group{
|
|
ID: entry.DN,
|
|
Name: entry.GetAttributeValue("cn"),
|
|
Description: entry.GetAttributeValue("description"),
|
|
Members: members,
|
|
})
|
|
}
|
|
return groups, nil
|
|
}
|
|
|
|
// ListUsers returns all user records from the LLDAP directory.
|
|
// It performs an LDAP search with filter (objectClass=inetOrgPerson) to list every user,
|
|
// then validates each against the canonical LDAP schema.
|
|
func (a *LDAPAdapter) ListUsers(ctx context.Context) ([]domain.User, error) {
|
|
conn, err := a.dial()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer conn.Close()
|
|
|
|
req := ldap.NewSearchRequest(
|
|
a.cfg.userBaseDN(),
|
|
ldap.ScopeWholeSubtree,
|
|
ldap.NeverDerefAliases,
|
|
0, 0, false,
|
|
"(objectClass=inetOrgPerson)",
|
|
[]string{"dn", "uid", "cn", "sn", "mail", "memberOf"},
|
|
nil,
|
|
)
|
|
result, err := conn.Search(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("lldap: list users search: %w", err)
|
|
}
|
|
|
|
users := make([]domain.User, 0, len(result.Entries))
|
|
for _, entry := range result.Entries {
|
|
user := mapEntryToUser(entry)
|
|
|
|
snap := validator.Snapshot{Users: []domain.User{user}}
|
|
report := validator.Validate(snap, validator.ModeProvisioning)
|
|
if !report.Passed {
|
|
// Non-fatal: return the user with a warning embedded in LDAPAttributes.
|
|
if user.LDAPAttributes == nil {
|
|
user.LDAPAttributes = make(map[string]string)
|
|
}
|
|
user.LDAPAttributes["_validation_warning"] = validationSummary(report)
|
|
}
|
|
|
|
users = append(users, user)
|
|
}
|
|
return users, nil
|
|
}
|
|
|
|
// ValidatePassword returns true when the username and password are valid.
|
|
// It opens a second connection and attempts a user bind. Bind failure (wrong
|
|
// credentials) returns false, nil. Infrastructure errors return false, err.
|
|
func (a *LDAPAdapter) ValidatePassword(ctx context.Context, username, password string) (bool, error) {
|
|
// First resolve the user DN.
|
|
conn, err := a.dial()
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
|
|
filter := fmt.Sprintf("(uid=%s)", ldap.EscapeFilter(username))
|
|
req := ldap.NewSearchRequest(
|
|
a.cfg.userBaseDN(),
|
|
ldap.ScopeWholeSubtree,
|
|
ldap.NeverDerefAliases,
|
|
0, 0, false,
|
|
filter,
|
|
[]string{"dn"},
|
|
nil,
|
|
)
|
|
result, err := conn.Search(req)
|
|
conn.Close()
|
|
if err != nil {
|
|
return false, fmt.Errorf("lldap: DN lookup for user %q: %w", username, err)
|
|
}
|
|
if len(result.Entries) == 0 {
|
|
return false, nil
|
|
}
|
|
|
|
userDN := result.Entries[0].DN
|
|
|
|
// Attempt a user bind with the provided password using a fresh connection.
|
|
host := ldapHost(a.cfg.URL)
|
|
userConn, err := a.dialFn(host)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
defer userConn.Close()
|
|
|
|
if err := userConn.Bind(userDN, password); err != nil {
|
|
// Distinguish authentication failure from infrastructure error.
|
|
if ldap.IsErrorWithCode(err, ldap.LDAPResultInvalidCredentials) {
|
|
return false, nil
|
|
}
|
|
return false, fmt.Errorf("lldap: user bind for %q: %w", username, err)
|
|
}
|
|
return true, nil
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Attribute mapping helpers (LDAP → canonical domain model).
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// mapEntryToUser converts an LDAP entry to a canonical domain.User.
|
|
// Attribute mapping per spec:
|
|
// - uid → Username
|
|
// - cn → DisplayName (sn as fallback)
|
|
// - sn → DisplayName fallback if cn is empty
|
|
// - mail → Email
|
|
// - memberOf → Groups (DNs parsed to group names)
|
|
// - dn → ID (stable identifier)
|
|
func mapEntryToUser(entry *ldap.Entry) domain.User {
|
|
displayName := entry.GetAttributeValue("cn")
|
|
if displayName == "" {
|
|
displayName = entry.GetAttributeValue("sn")
|
|
}
|
|
|
|
memberOfs := entry.GetAttributeValues("memberOf")
|
|
groups := make([]string, 0, len(memberOfs))
|
|
for _, dn := range memberOfs {
|
|
groups = append(groups, groupNameFromDN(dn))
|
|
}
|
|
tenant, roles := identityEnvelopeFromGroups(groups)
|
|
|
|
return domain.User{
|
|
ID: entry.DN,
|
|
Username: entry.GetAttributeValue("uid"),
|
|
DisplayName: displayName,
|
|
Email: entry.GetAttributeValue("mail"),
|
|
Groups: groups,
|
|
Roles: roles,
|
|
Tenant: tenant,
|
|
Enabled: true, // LLDAP does not expose a disabled flag in base schema
|
|
}
|
|
}
|
|
|
|
// identityEnvelopeFromGroups maps explicit tenant membership groups to the
|
|
// coarse IAM Profile envelope. Fine-grained authorization remains flex-auth's
|
|
// responsibility. Supported names are:
|
|
//
|
|
// tenant:<kind>:<slug>:users
|
|
// tenant:<kind>:<slug>:admins
|
|
//
|
|
// Multiple tenant envelopes are deliberately ignored because an interactive
|
|
// token must carry one unambiguous active tenant.
|
|
func identityEnvelopeFromGroups(groups []string) (string, []string) {
|
|
tenants := make(map[string]bool)
|
|
admins := make(map[string]bool)
|
|
platformOperator := false
|
|
for _, group := range groups {
|
|
switch {
|
|
case group == "net-kingdom-admins":
|
|
platformOperator = true
|
|
case strings.HasPrefix(group, "tenant:") && strings.HasSuffix(group, ":users"):
|
|
tenants[strings.TrimSuffix(group, ":users")] = true
|
|
case strings.HasPrefix(group, "tenant:") && strings.HasSuffix(group, ":admins"):
|
|
tenant := strings.TrimSuffix(group, ":admins")
|
|
tenants[tenant] = true
|
|
admins[tenant] = true
|
|
}
|
|
}
|
|
// Preserve the established bootstrap/platform-root group as an explicit
|
|
// platform control-plane identity. Tenant envelopes must never narrow or
|
|
// ambiguously reinterpret platform authority.
|
|
if platformOperator {
|
|
return "tenant:platform", []string{"user", "platform-operator"}
|
|
}
|
|
if len(tenants) != 1 {
|
|
return "", []string{}
|
|
}
|
|
var tenant string
|
|
for candidate := range tenants {
|
|
tenant = candidate
|
|
}
|
|
roles := []string{"user"}
|
|
if admins[tenant] {
|
|
roles = append(roles, "tenant-admin")
|
|
}
|
|
return tenant, roles
|
|
}
|
|
|
|
// groupNameFromDN extracts the cn value from an LDAP DN such as
|
|
// "cn=admins,ou=groups,dc=netkingdom,dc=local" → "admins".
|
|
// If parsing fails the full DN is returned unchanged.
|
|
func groupNameFromDN(dn string) string {
|
|
parts := strings.SplitN(dn, ",", 2)
|
|
if len(parts) == 0 {
|
|
return dn
|
|
}
|
|
kv := strings.SplitN(parts[0], "=", 2)
|
|
if len(kv) == 2 {
|
|
return kv[1]
|
|
}
|
|
return dn
|
|
}
|
|
|
|
// ldapHost extracts host:port from a URL string; falls back to the raw value.
|
|
func ldapHost(rawURL string) string {
|
|
u, err := url.Parse(rawURL)
|
|
if err != nil || u.Host == "" {
|
|
return rawURL
|
|
}
|
|
return u.Host
|
|
}
|
|
|
|
// validationSummary produces a short string summarising all failed rules.
|
|
func validationSummary(r validator.Report) string {
|
|
var msgs []string
|
|
for _, rule := range r.Structural {
|
|
if !rule.Passed {
|
|
msgs = append(msgs, rule.Message)
|
|
}
|
|
}
|
|
for _, rule := range r.Semantic {
|
|
if !rule.Passed {
|
|
msgs = append(msgs, rule.Message)
|
|
}
|
|
}
|
|
return strings.Join(msgs, "; ")
|
|
}
|
|
|
|
// Compile-time checks: the adapter satisfies the directory contracts it claims.
|
|
var (
|
|
_ domain.UserRepository = (*LDAPAdapter)(nil)
|
|
_ domain.GroupLister = (*LDAPAdapter)(nil)
|
|
)
|
|
|
|
// Ping reports whether the directory is reachable and the configured service
|
|
// credentials still bind. It is the readiness probe for LLDAP (KEY-WP-0025).
|
|
//
|
|
// A bind rather than a bare dial: a rotated or revoked service password leaves
|
|
// the port open and every lookup failing, which is exactly the state readiness
|
|
// exists to catch. dial already binds, so this opens and closes one connection.
|
|
func (a *LDAPAdapter) Ping(ctx context.Context) error {
|
|
type dialResult struct {
|
|
conn LDAPConn
|
|
err error
|
|
}
|
|
// dial is synchronous and has no context, so race it against the caller's
|
|
// deadline rather than letting a hung directory outlive the probe budget.
|
|
results := make(chan dialResult, 1)
|
|
go func() {
|
|
conn, err := a.dial()
|
|
results <- dialResult{conn, err}
|
|
}()
|
|
|
|
select {
|
|
case <-ctx.Done():
|
|
go func() {
|
|
if result := <-results; result.err == nil {
|
|
result.conn.Close()
|
|
}
|
|
}()
|
|
return ctx.Err()
|
|
case result := <-results:
|
|
if result.err != nil {
|
|
return result.err
|
|
}
|
|
result.conn.Close()
|
|
return nil
|
|
}
|
|
}
|