Files
OrchestrAD/backend/internal/services/connection_service.go
Alphaeus Mote 5301b47f16 fix(crypto): explain that a decryption failure means the secret key changed
Stored credential secrets are encrypted with a key derived from
ORCHESTRAD_SECRET_KEY. When that value changes, the secrets are intact but
unreadable, and the only symptom was an opaque "decryption failed" surfacing
deep inside an unrelated operation:

  "preview failed: building LDAP client: failed to decrypt credential:
   decryption failed"

Nothing pointed at the real cause, so the error is now self-diagnosing:

- ErrDecryptionFailed states that the data was encrypted under a different
  ORCHESTRAD_SECRET_KEY (or is corrupted). GCM auth failure on a well-formed
  ciphertext is overwhelmingly a wrong-key case.
- The three credential decrypt sites name the credential, so the operator
  knows which password to restore or re-enter.
- New services.CheckSecretKey verifies every stored secret against the
  current key. It runs at startup (LogSecretKeyCheck) and in `doctor`, so a
  mismatched key is reported once, loudly, at the moment it is first used
  rather than during the next rule run. A correctly-sized but *different*
  key passed doctor's existing length check and still broke every bind.

Not fatal: the server still starts, since an operator may be mid-migration
or may intend to re-enter the secrets.

Verified on the demo instance: starting with a wrong key logs
"1 of 1 stored credential secret(s) CANNOT be decrypted ... [OrchestrAD]",
and the rule preview error now names both the credential and the key.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-03 10:59:43 -04:00

605 lines
17 KiB
Go

// Package services provides business logic services
package services
import (
"database/sql"
"fmt"
"regexp"
"sort"
"strings"
"sync"
"time"
"github.com/Grace-Solutions/OrchestrAD/internal/crypto"
"github.com/Grace-Solutions/OrchestrAD/internal/directory/ldap"
"github.com/Grace-Solutions/OrchestrAD/internal/logging"
"github.com/Grace-Solutions/OrchestrAD/internal/models"
"github.com/Grace-Solutions/OrchestrAD/internal/repository"
"github.com/Grace-Solutions/OrchestrAD/internal/types"
goldap "github.com/go-ldap/ldap/v3"
)
// ConnectionService handles AD connection operations
type ConnectionService struct {
connRepo *repository.ConnectionRepository
credRepo *repository.CredentialRepository
encryptor *crypto.Encryptor
logger *logging.Logger
// schemaCache memoises the per-(connection, objectType) applicable attribute
// name set so the class-hierarchy walk is not repeated on every keystroke.
schemaCache sync.Map // key "connID|objectType" -> *attrSetCache
}
type attrSetCache struct {
names []string
expires time.Time
}
// NewConnectionService creates a new ConnectionService
func NewConnectionService(db *sql.DB, encryptor *crypto.Encryptor, logger *logging.Logger) *ConnectionService {
return &ConnectionService{
connRepo: repository.NewConnectionRepository(db),
credRepo: repository.NewCredentialRepository(db),
encryptor: encryptor,
logger: logger,
}
}
// TestResult represents the result of a connection test
type TestResult struct {
Success bool `json:"success"`
Message string `json:"message"`
TestDetails []TestDetail `json:"testDetails"`
TestedAt time.Time `json:"testedAt"`
}
// TestDetail represents a single test step result
type TestDetail struct {
Step string `json:"step"`
Success bool `json:"success"`
Message string `json:"message"`
Latency time.Duration `json:"latency"`
}
// TestConnection tests an AD connection
func (s *ConnectionService) TestConnection(conn *models.ADConnection) (*TestResult, error) {
result := &TestResult{
TestedAt: time.Now().UTC(),
}
// Build LDAP config
config, err := s.BuildLDAPConfig(conn)
if err != nil {
result.Message = fmt.Sprintf("Configuration error: %v", err)
return result, nil
}
client := ldap.NewClient(config)
// Test 1: TCP connectivity
start := time.Now()
err = client.Connect()
connectLatency := time.Since(start)
result.TestDetails = append(result.TestDetails, TestDetail{
Step: "TCP Connection",
Success: err == nil,
Message: errorMessage(err),
Latency: connectLatency,
})
if err != nil {
result.Message = "Connection failed"
return result, nil
}
defer client.Close()
// Test 2: Bind/authentication
start = time.Now()
err = client.Bind()
bindLatency := time.Since(start)
result.TestDetails = append(result.TestDetails, TestDetail{
Step: "LDAP Bind",
Success: err == nil,
Message: errorMessage(err),
Latency: bindLatency,
})
if err != nil {
result.Message = "Authentication failed"
return result, nil
}
// Test 3: Root DN accessibility
start = time.Now()
err = testRootDN(client, conn.RootDN)
rootLatency := time.Since(start)
result.TestDetails = append(result.TestDetails, TestDetail{
Step: "Root DN Access",
Success: err == nil,
Message: errorMessage(err),
Latency: rootLatency,
})
if err != nil {
result.Message = "Root DN not accessible"
return result, nil
}
result.Success = true
result.Message = "All tests passed"
return result, nil
}
// BuildClient returns a connected and bound LDAP client for the given connection.
// Caller is responsible for calling Close() on the returned client.
func (s *ConnectionService) BuildClient(conn *models.ADConnection) (*ldap.Client, error) {
config, err := s.BuildLDAPConfig(conn)
if err != nil {
return nil, err
}
client := ldap.NewClient(config)
if err := client.Connect(); err != nil {
return nil, fmt.Errorf("connect failed: %w", err)
}
if err := client.Bind(); err != nil {
client.Close()
return nil, fmt.Errorf("bind failed: %w", err)
}
return client, nil
}
// BuildLDAPConfig constructs an LDAP config from a connection model and
// resolves any referenced credential.
func (s *ConnectionService) BuildLDAPConfig(conn *models.ADConnection) (*ldap.Config, error) {
hosts := strings.Split(conn.Hosts, ",")
for i := range hosts {
hosts[i] = strings.TrimSpace(hosts[i])
}
config := &ldap.Config{
Hosts: hosts,
Port: conn.Port,
UseTLS: conn.UseTLS,
UseStartTLS: conn.UseStartTLS,
AllowInvalidCerts: conn.AllowInvalidCerts,
RootDN: conn.RootDN,
Timeout: time.Duration(conn.TimeoutSeconds) * time.Second,
PageSize: conn.PageSize,
}
if conn.BindDN != nil {
config.BindDN = *conn.BindDN
}
// Get credential if referenced
if conn.CredentialID != nil {
cred, err := s.credRepo.GetByID(*conn.CredentialID)
if err != nil {
return nil, fmt.Errorf("failed to get credential: %w", err)
}
if cred == nil {
return nil, fmt.Errorf("credential not found")
}
if cred.Username != nil {
config.BindDN = *cred.Username
}
if cred.EncryptedSecret != nil {
password, err := s.encryptor.Decrypt(*cred.EncryptedSecret)
if err != nil {
return nil, fmt.Errorf("failed to decrypt credential %q: %w", cred.Name, err)
}
config.BindPassword = string(password)
}
}
return config, nil
}
// QueryPreviewInput describes an ad-hoc LDAP search executed via an existing connection.
type QueryPreviewInput struct {
BaseDN string
Scope string
Filter string
Attributes []string
Limit int
}
// QueryPreviewEntry is a single search result returned by QueryPreview.
type QueryPreviewEntry struct {
DN string `json:"dn"`
Attributes map[string][]string `json:"attributes"`
}
// QueryPreviewResult holds the outcome of a query preview run.
type QueryPreviewResult struct {
Entries []QueryPreviewEntry `json:"entries"`
Truncated bool `json:"truncated"`
Count int `json:"count"`
}
// QueryPreview runs a user-supplied LDAP filter against an existing AD connection and
// returns up to input.Limit entries so callers can preview the result set.
func (s *ConnectionService) QueryPreview(conn *models.ADConnection, input QueryPreviewInput) (*QueryPreviewResult, error) {
if input.Filter == "" {
return nil, fmt.Errorf("filter is required")
}
baseDN := input.BaseDN
if baseDN == "" {
baseDN = conn.RootDN
}
scope := mapScope(input.Scope)
limit := input.Limit
if limit <= 0 || limit > 500 {
limit = 100
}
attrs := input.Attributes
if len(attrs) == 0 {
attrs = []string{"cn", "distinguishedName", "objectClass", "sAMAccountName"}
}
client, err := s.BuildClient(conn)
if err != nil {
return nil, err
}
defer client.Close()
entries, err := client.Search(baseDN, scope, input.Filter, attrs)
if err != nil {
return nil, err
}
result := &QueryPreviewResult{Entries: make([]QueryPreviewEntry, 0, len(entries))}
for _, entry := range entries {
if len(result.Entries) >= limit {
result.Truncated = true
break
}
preview := QueryPreviewEntry{
DN: entry.DN,
Attributes: map[string][]string{},
}
for _, attr := range entry.Attributes {
preview.Attributes[attr.Name] = ldap.FormatAttributeValues(attr)
}
result.Entries = append(result.Entries, preview)
}
result.Count = len(result.Entries)
return result, nil
}
// DirectoryObject is a lightweight directory entry returned by SearchDirectory
// for operator-facing pickers (target groups, base/target OUs).
type DirectoryObject struct {
DN string `json:"dn"`
Name string `json:"name"`
CanonicalName string `json:"canonicalName"`
ObjectType string `json:"objectType"`
}
// SearchDirectory finds groups or organizational units (and users/computers)
// matching an optional substring, for use by the rule editor's pickers.
// objectType is one of "Group", "OU", "User", "Computer".
func (s *ConnectionService) SearchDirectory(conn *models.ADConnection, objectType, q string, limit int) ([]DirectoryObject, error) {
if limit <= 0 || limit > 200 {
limit = 50
}
var classFilter, nameAttr string
switch objectType {
case "OU", "OrganizationalUnit":
classFilter, nameAttr = "(objectClass=organizationalUnit)", "ou"
case "User":
classFilter, nameAttr = "(&(objectCategory=person)(objectClass=user))", "cn"
case "Computer":
classFilter, nameAttr = "(objectClass=computer)", "cn"
default: // Group
objectType, classFilter, nameAttr = "Group", "(objectClass=group)", "cn"
}
filter := classFilter
if q = strings.TrimSpace(q); q != "" {
esc := ldap.EscapeFilterValue(q)
if nameAttr == "ou" {
filter = fmt.Sprintf("(&%s(ou=*%s*))", classFilter, esc)
} else {
filter = fmt.Sprintf("(&%s(|(cn=*%s*)(sAMAccountName=*%s*)))", classFilter, esc, esc)
}
}
client, err := s.BuildClient(conn)
if err != nil {
return nil, err
}
defer client.Close()
entries, err := client.Search(conn.RootDN, goldap.ScopeWholeSubtree, filter,
[]string{"cn", "ou", "distinguishedName", "canonicalName"})
if err != nil {
return nil, err
}
out := make([]DirectoryObject, 0, limit)
for _, entry := range entries {
if len(out) >= limit {
break
}
name := entry.GetAttributeValue(nameAttr)
if name == "" {
name = entry.GetAttributeValue("cn")
}
out = append(out, DirectoryObject{
DN: entry.DN,
Name: name,
CanonicalName: ldap.CanonicalName(entry.DN, entry.GetAttributeValue("canonicalName")),
ObjectType: objectType,
})
}
return out, nil
}
// AttributeInfo is a schema attribute offered to the filter builder.
type AttributeInfo struct {
Name string `json:"name"`
Description string `json:"description,omitempty"`
}
// attrNameRe restricts attribute names to the LDAP descriptor charset, so a
// name can be placed into a filter without escaping / injection risk.
var attrNameRe = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9-]*$`)
// classForObjectType maps an OrchestrAD object type to its AD classSchema
// lDAPDisplayName, the entry point for the attribute-applicability walk.
func classForObjectType(objectType string) string {
switch objectType {
case "Computer":
return "computer"
case "Group":
return "group"
default:
return "user"
}
}
// SchemaAttributes returns the schema attributes that apply to objectType
// (User/Computer/Group), optionally narrowed to those whose name contains q.
// The applicable-attribute set is derived from the class hierarchy and cached.
func (s *ConnectionService) SchemaAttributes(conn *models.ADConnection, objectType, q string, limit int) ([]AttributeInfo, error) {
q = strings.TrimSpace(q)
if limit <= 0 || limit > 500 {
limit = 50
}
client, err := s.BuildClient(conn)
if err != nil {
return nil, err
}
defer client.Close()
schemaNC, err := s.schemaNamingContext(client)
if err != nil {
return nil, err
}
names, err := s.applicableAttributeNames(client, conn.ID, schemaNC, objectType)
if err != nil {
return nil, err
}
// Filter by substring, cap.
ql := strings.ToLower(q)
matched := make([]string, 0, limit)
for _, n := range names {
if ql == "" || strings.Contains(strings.ToLower(n), ql) {
matched = append(matched, n)
if len(matched) >= limit {
break
}
}
}
if len(matched) == 0 {
return []AttributeInfo{}, nil
}
// Fetch descriptions for the matched subset in a single OR-filter search.
descByName := s.attributeDescriptions(client, schemaNC, matched)
out := make([]AttributeInfo, 0, len(matched))
for _, n := range matched {
out = append(out, AttributeInfo{Name: n, Description: descByName[strings.ToLower(n)]})
}
return out, nil
}
func (s *ConnectionService) schemaNamingContext(client *ldap.Client) (string, error) {
root, err := client.SearchOne("", goldap.ScopeBaseObject, "(objectClass=*)", []string{"schemaNamingContext"})
if err != nil {
return "", fmt.Errorf("reading RootDSE: %w", err)
}
if root == nil {
return "", fmt.Errorf("RootDSE not available")
}
nc := root.GetAttributeValue("schemaNamingContext")
if nc == "" {
return "", fmt.Errorf("directory does not expose a schema naming context")
}
return nc, nil
}
// applicableAttributeNames returns the sorted set of attribute lDAPDisplayNames
// that may be set on objectType, by walking the classSchema hierarchy
// (subClassOf up to top, plus auxiliary classes) and unioning each class's
// may/must-contain attributes. Cached per connection+objectType for 10 minutes.
func (s *ConnectionService) applicableAttributeNames(client *ldap.Client, connID, schemaNC, objectType string) ([]string, error) {
cacheKey := connID + "|" + objectType
if v, ok := s.schemaCache.Load(cacheKey); ok {
if c := v.(*attrSetCache); time.Now().Before(c.expires) {
return c.names, nil
}
}
attrSet := map[string]bool{}
visited := map[string]bool{}
queue := []string{classForObjectType(objectType)}
for len(queue) > 0 {
cls := queue[0]
queue = queue[1:]
if cls == "" || visited[cls] {
continue
}
visited[cls] = true
entry, err := client.SearchOne(schemaNC, goldap.ScopeSingleLevel,
fmt.Sprintf("(&(objectClass=classSchema)(lDAPDisplayName=%s))", ldap.EscapeFilterValue(cls)),
[]string{"mayContain", "systemMayContain", "mustContain", "systemMustContain",
"subClassOf", "auxiliaryClass", "systemAuxiliaryClass"})
if err != nil {
return nil, err
}
if entry == nil {
continue
}
for _, a := range []string{"mayContain", "systemMayContain", "mustContain", "systemMustContain"} {
for _, v := range entry.GetAttributeValues(a) {
attrSet[v] = true
}
}
for _, a := range []string{"subClassOf", "auxiliaryClass", "systemAuxiliaryClass"} {
for _, v := range entry.GetAttributeValues(a) {
if v != "" && v != cls {
queue = append(queue, v)
}
}
}
}
names := make([]string, 0, len(attrSet))
for n := range attrSet {
names = append(names, n)
}
sort.Slice(names, func(i, j int) bool { return strings.ToLower(names[i]) < strings.ToLower(names[j]) })
s.schemaCache.Store(cacheKey, &attrSetCache{names: names, expires: time.Now().Add(10 * time.Minute)})
return names, nil
}
// attributeDescriptions fetches adminDescription for a set of attribute names
// in one search, returning a lowercase-name-keyed map.
func (s *ConnectionService) attributeDescriptions(client *ldap.Client, schemaNC string, names []string) map[string]string {
if len(names) == 0 {
return map[string]string{}
}
var b strings.Builder
b.WriteString("(&(objectClass=attributeSchema)(|")
for _, n := range names {
b.WriteString("(lDAPDisplayName=")
b.WriteString(ldap.EscapeFilterValue(n))
b.WriteString(")")
}
b.WriteString("))")
out := map[string]string{}
entries, err := client.SearchWithLimit(schemaNC, goldap.ScopeSingleLevel, b.String(),
[]string{"lDAPDisplayName", "adminDescription"}, len(names))
if err != nil {
return out // descriptions are best-effort
}
for _, e := range entries {
name := e.GetAttributeValue("lDAPDisplayName")
if name != "" {
out[strings.ToLower(name)] = e.GetAttributeValue("adminDescription")
}
}
return out
}
// DistinctAttributeValues samples objects of objectType and returns the
// distinct values present for a single attribute, optionally narrowed by a
// substring q and a base DN. Bounded by a scan cap so it stays cheap on large
// directories.
func (s *ConnectionService) DistinctAttributeValues(conn *models.ADConnection, objectType, attribute, q, baseDN string, limit int) ([]string, error) {
attribute = strings.TrimSpace(attribute)
if !attrNameRe.MatchString(attribute) {
return nil, fmt.Errorf("invalid attribute name")
}
if limit <= 0 || limit > 200 {
limit = 50
}
if baseDN == "" {
baseDN = conn.RootDN
}
filter := fmt.Sprintf("(&%s(%s=*))", ldap.ObjectFilter(types.ObjectType(objectType)), attribute)
if q = strings.TrimSpace(q); q != "" {
filter = fmt.Sprintf("(&%s(%s=*%s*))", ldap.ObjectFilter(types.ObjectType(objectType)), attribute, ldap.EscapeFilterValue(q))
}
client, err := s.BuildClient(conn)
if err != nil {
return nil, err
}
defer client.Close()
// Scan up to ~2000 objects; that is plenty to surface the common values
// without walking an entire large directory.
entries, err := client.SearchWithLimit(baseDN, goldap.ScopeWholeSubtree, filter, []string{attribute}, 2000)
if err != nil {
return nil, err
}
seen := make(map[string]bool)
out := make([]string, 0, limit)
for _, e := range entries {
for _, v := range e.GetAttributeValues(attribute) {
key := strings.ToLower(v)
if v == "" || seen[key] {
continue
}
seen[key] = true
out = append(out, v)
if len(out) >= limit {
break
}
}
if len(out) >= limit {
break
}
}
sort.Slice(out, func(i, j int) bool { return strings.ToLower(out[i]) < strings.ToLower(out[j]) })
return out, nil
}
func mapScope(scope string) int {
switch scope {
case "base", "Base", "BaseObject":
return goldap.ScopeBaseObject
case "one", "One", "OneLevel", "SingleLevel":
return goldap.ScopeSingleLevel
default:
return goldap.ScopeWholeSubtree
}
}
func testRootDN(client *ldap.Client, rootDN string) error {
exists, err := client.Exists(rootDN)
if err != nil {
return err
}
if !exists {
return fmt.Errorf("root DN does not exist")
}
return nil
}
func errorMessage(err error) string {
if err == nil {
return "OK"
}
return err.Error()
}