feat(ldap): perform user/group writes via the FreeIPA API
Raw LDAP adds/modifies bypassed FreeIPA's framework, so group/user creates failed with Object Class Violation (no gidNumber/uidNumber/ipaUniqueID) and deletes/mods needed ACIs the bind account couldn't exercise as a plain LDAP write. Route all MUTATIONS through the FreeIPA JSON-RPC API instead; reads stay on direct LDAP. - internal/client/freeipa.go: new JSON-RPC client (form login_password → ipa_session cookie, re-auth on 401, multi-server failover, TLS via the configured CA). Derives the API host + login uid from the LDAP config. - internal/service/ldap.go: CreateGroup/UpdateGroup/DeleteGroup/AddGroupMembers/ RemoveGroupMember → group_add/_mod/_del/_add_member/_remove_member; CreateUser/UpdateUser/DisableUser/ResetPassword → user_add/_mod/_disable (+_enable)/passwd. Services map → addattr(objectclass)/setattr. Writes error cleanly when the IPA client is unconfigured. - cmd/server/main.go: build the FreeIPA client from the LDAP config and inject it into the LDAP service. Verified live: group create (IPA-assigned gidNumber), get, add/remove member, delete all succeed; reads unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
226
internal/client/freeipa.go
Normal file
226
internal/client/freeipa.go
Normal file
@@ -0,0 +1,226 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
// FreeIPAClient talks to the FreeIPA JSON-RPC management API
|
||||
// (/ipa/session/json). It is used for MUTATIONS (user/group add, modify,
|
||||
// delete, membership, password, enable/disable) because the IPA framework
|
||||
// assigns uidNumber/gidNumber/ipaUniqueID, sets the correct objectClasses and
|
||||
// enforces IPA's own logic — none of which a raw LDAP add does. Reads stay on
|
||||
// direct LDAP (LDAPClient).
|
||||
//
|
||||
// Auth: form login (login_password) with the svc account uid + password →
|
||||
// ipa_session cookie. The cookie is reused and refreshed on 401. Requests fail
|
||||
// over across the configured IPA servers.
|
||||
type FreeIPAClient struct {
|
||||
servers []string // bare hostnames, e.g. fihelvid01.gosec.auth
|
||||
user string // uid (extracted from the bind DN)
|
||||
pass string
|
||||
http *http.Client
|
||||
logger zerolog.Logger
|
||||
|
||||
mu sync.Mutex
|
||||
cookie string // "ipa_session=..."
|
||||
active string // hostname the current cookie belongs to
|
||||
}
|
||||
|
||||
// NewFreeIPAClient builds the client from the LDAP config. `servers` are the
|
||||
// ldap(s):// URLs; the IPA API host is the same host on https/443. `bindDN` is
|
||||
// the full svc DN (uid=svc-ops-api,...); the uid is extracted for the API login.
|
||||
func NewFreeIPAClient(servers []string, bindDN, password, caFile string, logger zerolog.Logger) (*FreeIPAClient, error) {
|
||||
hosts := make([]string, 0, len(servers))
|
||||
for _, s := range servers {
|
||||
h := s
|
||||
if i := strings.Index(h, "://"); i >= 0 {
|
||||
h = h[i+3:]
|
||||
}
|
||||
if i := strings.IndexByte(h, ':'); i >= 0 {
|
||||
h = h[:i]
|
||||
}
|
||||
if h != "" {
|
||||
hosts = append(hosts, h)
|
||||
}
|
||||
}
|
||||
if len(hosts) == 0 {
|
||||
return nil, fmt.Errorf("no IPA servers configured")
|
||||
}
|
||||
|
||||
uid := bindDN
|
||||
for _, part := range strings.Split(bindDN, ",") {
|
||||
part = strings.TrimSpace(part)
|
||||
if strings.HasPrefix(strings.ToLower(part), "uid=") {
|
||||
uid = part[4:]
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
tlsCfg := &tls.Config{MinVersion: tls.VersionTLS12}
|
||||
if caFile != "" {
|
||||
caCert, err := os.ReadFile(caFile)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read IPA CA file: %w", err)
|
||||
}
|
||||
pool := x509.NewCertPool()
|
||||
if !pool.AppendCertsFromPEM(caCert) {
|
||||
return nil, fmt.Errorf("parse IPA CA file %s", caFile)
|
||||
}
|
||||
tlsCfg.RootCAs = pool
|
||||
}
|
||||
|
||||
return &FreeIPAClient{
|
||||
servers: hosts,
|
||||
user: uid,
|
||||
pass: password,
|
||||
http: &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
Transport: &http.Transport{TLSClientConfig: tlsCfg},
|
||||
},
|
||||
logger: logger.With().Str("component", "freeipa").Logger(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// login authenticates against one host and stores the session cookie.
|
||||
func (c *FreeIPAClient) login(host string) error {
|
||||
form := url.Values{"user": {c.user}, "password": {c.pass}}
|
||||
req, err := http.NewRequest("POST",
|
||||
fmt.Sprintf("https://%s/ipa/session/login_password", host),
|
||||
strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("Accept", "text/plain")
|
||||
req.Header.Set("Referer", fmt.Sprintf("https://%s/ipa", host))
|
||||
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("IPA login failed on %s: HTTP %d %s", host, resp.StatusCode, strings.TrimSpace(string(body)))
|
||||
}
|
||||
for _, ck := range resp.Cookies() {
|
||||
if ck.Name == "ipa_session" {
|
||||
c.cookie = ck.Name + "=" + ck.Value
|
||||
c.active = host
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("IPA login on %s returned no ipa_session cookie", host)
|
||||
}
|
||||
|
||||
// ensureSession logs in (trying each server) if there is no current cookie.
|
||||
func (c *FreeIPAClient) ensureSession() error {
|
||||
if c.cookie != "" {
|
||||
return nil
|
||||
}
|
||||
var lastErr error
|
||||
for _, h := range c.servers {
|
||||
if err := c.login(h); err != nil {
|
||||
lastErr = err
|
||||
c.logger.Warn().Err(err).Str("host", h).Msg("IPA login attempt failed")
|
||||
continue
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("all IPA logins failed: %w", lastErr)
|
||||
}
|
||||
|
||||
type ipaError struct {
|
||||
Code int `json:"code"`
|
||||
Name string `json:"name"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
type ipaResponse struct {
|
||||
Result json.RawMessage `json:"result"`
|
||||
Error *ipaError `json:"error"`
|
||||
}
|
||||
|
||||
// Command invokes an IPA method. `args` are the positional primary keys (e.g.
|
||||
// the cn or uid); `options` are the keyword options. Returns the raw `result`.
|
||||
// Re-authenticates once on 401. Surfaces IPA's structured error on failure.
|
||||
func (c *FreeIPAClient) Command(method string, args []interface{}, options map[string]interface{}) (json.RawMessage, error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
if options == nil {
|
||||
options = map[string]interface{}{}
|
||||
}
|
||||
payload, err := json.Marshal(map[string]interface{}{
|
||||
"method": method,
|
||||
"params": []interface{}{args, options},
|
||||
"id": 0,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := c.ensureSession(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
res, status, err := c.post(payload)
|
||||
if (status == http.StatusUnauthorized) || (err == nil && status == http.StatusForbidden) {
|
||||
// Session expired — re-login once and retry.
|
||||
c.cookie = ""
|
||||
if err2 := c.ensureSession(); err2 != nil {
|
||||
return nil, err2
|
||||
}
|
||||
res, status, err = c.post(payload)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if status != http.StatusOK {
|
||||
return nil, fmt.Errorf("IPA %s: HTTP %d: %s", method, status, strings.TrimSpace(string(res)))
|
||||
}
|
||||
|
||||
var parsed ipaResponse
|
||||
if err := json.Unmarshal(res, &parsed); err != nil {
|
||||
return nil, fmt.Errorf("IPA %s: invalid response: %w", method, err)
|
||||
}
|
||||
if parsed.Error != nil {
|
||||
return nil, fmt.Errorf("IPA %s: %s (%s)", method, parsed.Error.Message, parsed.Error.Name)
|
||||
}
|
||||
return parsed.Result, nil
|
||||
}
|
||||
|
||||
// post sends one JSON-RPC request to the active server.
|
||||
func (c *FreeIPAClient) post(payload []byte) ([]byte, int, error) {
|
||||
req, err := http.NewRequest("POST",
|
||||
fmt.Sprintf("https://%s/ipa/session/json", c.active),
|
||||
bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("Referer", fmt.Sprintf("https://%s/ipa", c.active))
|
||||
req.Header.Set("Cookie", c.cookie)
|
||||
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
return body, resp.StatusCode, err
|
||||
}
|
||||
Reference in New Issue
Block a user