ldap

package
v0.9.669 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 5, 2026 License: MIT Imports: 22 Imported by: 0

Documentation

Overview

Package ldap is the enterprise auth provider — connects to a corporate LDAP/AD directory, authenticates users with their domain credentials, and resolves their group memberships into Coremetry roles via an admin-configurable mapping.

Designed for enterprise-style on-prem deployments:

  • LDAPS (port 636) is the default; StartTLS (389→TLS upgrade) is supported for legacy AD setups.
  • Custom CA paste field for internal CAs; SkipVerify toggle as last-resort escape hatch for self-signed certs.
  • Group→role mapping is the primary provisioning path. Pre- provisioned users (admin pinned a row) override the group map.
  • Bind password is stored in plaintext in system_settings — that was an explicit deployment-time decision; an env-keyed encrypt path can be bolted on later if needed.

sync.go — LDAP/AD group-membership sync engine (v0.8.526).

Enumerates the in-scope directory groups and, per group, chain-searches their effective (nested-inclusive) members, then persists the result to ClickHouse (internal/chstore/ldap_groups.go) and publishes it as an atomically-swapped in-memory snapshot for O(1) authz lookups.

Design (audit-driven):

  • Group identity is objectGUID (stable across AD rename/move), not DN.
  • Membership is resolved group→users via LDAP_MATCHING_RULE_IN_CHAIN on a paged USER search, never member;range retrieval.
  • The snapshot pointer is swapped ONLY on a fully successful Sync; any page/connection error aborts the round and leaves the prior snapshot (fail-stale, audit §7).
  • The directory I/O sits behind the narrow ldapSearcher interface so the engine is table-testable with a fake (sync_test.go, audit §12).

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

This section is empty.

Types

type AuthResult

type AuthResult struct {
	User LDAPUser
	Role string
	// RoleFromGroup — v0.8.528: true when Role came from an EXPLICIT
	// GroupRoleMap match, false when it fell back to DefaultRole (no
	// group matched). loginViaLDAP uses this to avoid clobbering an
	// admin's manual Users-page role grant with the default fallback on
	// re-login (operator-reported: LDAP user promoted to admin dropped
	// back to viewer next login).
	RoleFromGroup bool
}

AuthResult bundles the authenticated user + the role we resolved from their group memberships.

type Config

type Config struct {
	Enabled bool `json:"enabled"`

	// Connection
	Host       string `json:"host"`
	Port       int    `json:"port"`
	UseTLS     bool   `json:"useTLS"`   // direct ldaps:// (default port 636)
	StartTLS   bool   `json:"startTLS"` // upgrade plain → TLS on 389
	SkipVerify bool   `json:"skipVerify"`
	CACert     string `json:"caCert"` // PEM bundle for internal CA
	// CAFile (v0.8.526) — filesystem path to a PEM CA bundle. When set
	// AND readable it WINS over the inline CACert blob; empty falls back
	// to CACert (backward compat). A path, not a secret — safe to echo.
	CAFile string `json:"caFile,omitempty"`

	// Service account used to look up users / groups.
	BindDN       string `json:"bindDN"`
	BindPassword string `json:"bindPassword"`
	// BindPasswordFile / BindPasswordEnv (v0.8.526) — external references
	// for the bind secret so it need not live in the system_settings blob.
	// Precedence: File (if set & readable) > Env (if set & present) >
	// inline BindPassword blob (backward compat — never removed, the blob
	// path is a deliberate deployment option). Both hold a PATH / env-var
	// NAME, not the secret itself, so they round-trip through the API
	// unredacted.
	BindPasswordFile string `json:"bindPasswordFile,omitempty"`
	BindPasswordEnv  string `json:"bindPasswordEnv,omitempty"`

	// Search
	BaseDN           string `json:"baseDN"`
	UserSearchFilter string `json:"userSearchFilter"` // {{username}} placeholder
	UserAttribute    string `json:"userAttribute"`    // sAMAccountName | uid | mail
	EmailAttribute   string `json:"emailAttribute"`
	DisplayAttribute string `json:"displayAttribute"`
	// TeamAttribute (v0.8.430) — which directory attribute feeds
	// users.team on login. Operator-reported: the default
	// department→ou fallback surfaced the TOP division ("TEKNOLOJİ")
	// for every user because AD stores the division there; the actual
	// sub-team lives elsewhere (use /api/settings/ldap/inspect to find
	// where). "" = legacy department→ou; the special value "dn-ou"
	// takes the DEEPEST ou= RDN from the user's DN (the leaf-most OU
	// container — typically the sub-team in OU-per-team trees); any
	// other value is read as a literal attribute name with the legacy
	// chain as fallback.
	TeamAttribute string `json:"teamAttribute"`
	// TeamRegex (v0.8.434) — optional extraction on the RESOLVED team
	// source value, for directories that embed the sub-team inside a
	// composite attribute (operator's AD: displayName carries
	// "Ad Soyad (Bölüm) * ÜNVAN-Ekip" — TeamAttribute=displayName +
	// TeamRegex `-([^-]+)$` yields "Ekip"). First capture group wins
	// (whole match when the pattern has no group). NO match → team
	// stays EMPTY on purpose: the raw composite leaking into
	// users.team was the reported bug. Invalid pattern → ignored
	// (raw value passes through) and logged once at Configure.
	TeamRegex string `json:"teamRegex"`

	// Group lookup
	GroupSearchBase string `json:"groupSearchBase"`
	GroupFilter     string `json:"groupFilter"` // {{userDN}} placeholder
	// SkipMemberOfFetch drops `memberOf` from the user-search
	// attribute list. AD enforces MaxValRange (default 1500)
	// and MaxReceiveBuffer (1MB on some configs); a senior
	// user with thousands of nested group memberships trips
	// these and the login fails with LDAP_ADMIN_LIMIT_EXCEEDED
	// or a 1MB-cap error. Skipping memberOf moves the
	// authoritative membership lookup to the separate
	// GroupSearchBase + GroupFilter pass (LDAP_MATCHING_RULE_IN_CHAIN
	// recurses through nested groups cleanly), which pulls
	// only DN values without the per-user attribute bloat.
	// Required pre-req: GroupSearchBase must be set; otherwise
	// the auth fall-through has nothing to derive roles from.
	SkipMemberOfFetch bool `json:"skipMemberOfFetch"`

	// Role assignment
	DefaultRole  string             `json:"defaultRole"`  // role for users without group match
	GroupRoleMap []GroupRoleMapping `json:"groupRoleMap"` // first match wins (admin > editor > viewer)

	// GroupSync (v0.8.526) — periodic directory group→members snapshot.
	// Independent of the login-time group lookup: this enumerates the
	// in-scope groups and, per group, chain-searches their effective
	// members, persisting the result to ClickHouse for O(1) authz.
	GroupSync GroupSyncConfig `json:"groupSync"`
}

Config is the persisted LDAP connection + mapping definition.

Defaults are filled in by Normalize() so the config struct can be built from a half-empty PUT body and still produce sensible probes.

func (*Config) Normalize

func (c *Config) Normalize()

Normalize fills in Active-Directory-friendly defaults so half-filled configs produce a working probe. Mutates in place.

func (*Config) Sanitize

func (c *Config) Sanitize() Config

Sanitize returns a copy with the bind password cleared — used for API responses so the secret never round-trips back to the UI.

type Group added in v0.8.526

type Group struct {
	UID   string   `json:"uid"`
	CN    string   `json:"cn"`
	DN    string   `json:"dn"`
	Users []string `json:"users"`
}

Group is one directory group + its effective members (lowercase sAMAccountName, sorted). UID is the objectGUID uuid string.

type GroupRoleMapping

type GroupRoleMapping struct {
	Group string `json:"group"` // group DN (preferred) or CN — case-insensitive substring match
	Role  string `json:"role"`  // admin | editor | viewer
}

type GroupStore added in v0.8.526

type GroupStore interface {
	UpsertLdapGroups(ctx context.Context, rows []chstore.LdapGroupRow, syncedAt time.Time) (written, tombstoned int, err error)
	HydrateLdapGroups(ctx context.Context) ([]chstore.LdapGroupRow, error)
	LdapIdentityOverlap(ctx context.Context, aliases []string) (matched, total int, err error)
}

GroupStore is the persistence surface (implemented by *chstore.Store). Kept here with chstore-native types so the storage package carries no ldap dependency (feature → storage layering).

type GroupSummary added in v0.8.526

type GroupSummary struct {
	UID         string `json:"uid"`
	CN          string `json:"cn"`
	DN          string `json:"dn"`
	MemberCount int    `json:"memberCount"`
}

GroupSummary is one group in the status card (no member list).

type GroupSyncConfig added in v0.8.526

type GroupSyncConfig struct {
	Enabled bool `json:"enabled"`
	// SyncInterval / Timeout are duration strings ("30m", "60s") so the
	// system_settings JSON blob stays human-editable; parsed via
	// time.ParseDuration with the defaults below.
	SyncInterval string `json:"syncInterval"` // default 30m
	Timeout      string `json:"timeout"`      // default 60s per full sync
	// PageSize for SearchWithPaging — kept under AD's MaxPageSize (1000).
	PageSize uint32 `json:"pageSize"` // default 500
	// UsersBaseDN / UserFilter — the pre-filter narrowing the per-group
	// chain USER search (resolveUserFilter-style: no {{username}}). The
	// chain predicate (memberOf:…IN_CHAIN:=<groupDN>) is AND-ed on.
	UsersBaseDN string `json:"usersBaseDN"`
	UserFilter  string `json:"userFilter"` // default (objectClass=user)
	// UserNameAttribute — the member identity written to ldap_groups.users.
	UserNameAttribute string `json:"userNameAttribute"` // default sAMAccountName
	// GroupsBaseDN / GroupFilter — where + what to enumerate for the group
	// list. Empty GroupsBaseDN falls back to UsersBaseDN (audit §AÇIK-3:
	// (objectClass=group) paged enumerate under the include scope).
	GroupsBaseDN string `json:"groupsBaseDN"`
	GroupFilter  string `json:"groupFilter"` // default (objectClass=group)
	// IncludePrefixes / ExcludePrefixes — DN suffix-match whitelist /
	// blacklist over enumerated groups. "prefix" is the OU-chain DN suffix
	// (DNs read leaf→root as "CN=x,OU=y,DC=…"), so scope membership is a
	// case-insensitive strings.HasSuffix(dn, prefix). Empty include = all.
	IncludePrefixes []string `json:"includePrefixes"`
	ExcludePrefixes []string `json:"excludePrefixes"`
	// MaxGroupMembers — dev-group safety cap (audit §AÇIK-4). A group with
	// more effective members is TRUNCATED (sorted, first N kept) + logged
	// WARN + counted in Stats.Truncated. 0 → default 50000.
	MaxGroupMembers int `json:"maxGroupMembers"`
}

GroupSyncConfig is the persisted definition for the background LDAP/AD group-membership sync (audit §4). All directory queries reuse the parent Config's connection (dial + bind + TLS) — no second endpoint.

func (GroupSyncConfig) IntervalDuration added in v0.8.526

func (g GroupSyncConfig) IntervalDuration() time.Duration

IntervalDuration parses SyncInterval, falling back to 30m on empty / invalid input.

func (GroupSyncConfig) TimeoutDuration added in v0.8.526

func (g GroupSyncConfig) TimeoutDuration() time.Duration

TimeoutDuration parses Timeout, falling back to 60s on empty / invalid.

type InspectResult added in v0.8.430

type InspectResult struct {
	DN         string              `json:"dn"`
	DeepestOU  string              `json:"deepestOu"` // what teamAttribute="dn-ou" would yield
	Team       string              `json:"team"`      // what the CURRENT config yields
	Attributes map[string][]string `json:"attributes"`
	// TeamCandidates (v0.8.523) — attribute başına tıkla-seç ekip
	// çıkarım adayları (canlı önizlemeli); UI bunlardan birini seçince
	// TeamAttribute+TeamRegex otomatik dolar.
	TeamCandidates map[string][]TeamCandidate `json:"teamCandidates,omitempty"`
}

Authenticate runs the standard "search-then-bind" auth pattern:

  1. Service-bind (admin lookup credentials).
  2. Find the user by username (or email — `username` may be either, the configured UserSearchFilter handles it).
  3. Re-bind with the user's DN + entered password — that's the actual credential check.
  4. Resolve groups → role via the configured GroupRoleMap; fall back to DefaultRole.

InspectResult is one directory entry with EVERY attribute the service account can read — the discovery affordance behind GET /api/settings/ldap/inspect (v0.8.430). Operator use-case: "users.team yanlış attribute'tan geliyor — alt ekip hangi attribute'ta?" Binary values (photos, GUIDs) are summarized as [N bytes], never shipped raw.

type LDAPUser

type LDAPUser struct {
	DN          string `json:"dn"`
	Username    string `json:"username"`
	Email       string `json:"email"`
	DisplayName string `json:"displayName"`
	// Department / Company — directory org info (v0.8.266, operator:
	// "organizasyon, ad soyad, ekip bilgisi de gelsin"). AD names them
	// department/company; inetOrgPerson uses ou/o — dirText resolves
	// the fallback. Department feeds the users.team column on login,
	// Company the org column.
	Department string   `json:"department,omitempty"`
	Company    string   `json:"company,omitempty"`
	Groups     []string `json:"groups,omitempty"`
	// Photo — raw thumbnailPhoto (AD) / jpegPhoto (inetOrgPerson) bytes
	// (v0.8.238). Never serialized: the directory-search UI JSON must
	// not ship images; the login path persists it to the users row and
	// the photo endpoints serve it from there.
	Photo []byte `json:"-"`
}

LDAPUser is the lightweight projection of a directory entry that the UI consumes (search results + provisioning picker).

type PreviewGroup added in v0.8.526

type PreviewGroup struct {
	UID           string   `json:"uid"`
	CN            string   `json:"cn"`
	DN            string   `json:"dn"`
	MemberCount   int      `json:"memberCount"`
	SampleMembers []string `json:"sampleMembers"`
}

PreviewGroup is one sampled group in a dry-run.

type PreviewResult added in v0.8.526

type PreviewResult struct {
	TotalGroupsInScope int            `json:"totalGroupsInScope"`
	SampledGroups      int            `json:"sampledGroups"`
	Groups             []PreviewGroup `json:"groups"`
	Matched            int            `json:"matched"`
	TotalAliases       int            `json:"totalAliases"`
	MatchRatio         float64        `json:"matchRatio"`
	Warning            string         `json:"warning,omitempty"`
}

PreviewResult is the GET /api/admin/ldap/groupsync/preview payload — a live dry-run that NEVER writes CH or swaps the snapshot.

type Service

type Service struct {
	// contains filtered or unexported fields
}

Service holds the live config; mutates safely under RWMutex so the admin Settings PUT can swap config while a login is in flight.

func New

func New() *Service

func (*Service) Authenticate

func (s *Service) Authenticate(ctx context.Context, username, password string) (*AuthResult, error)

func (*Service) Configure

func (s *Service) Configure(incoming Config)

Configure swaps the live config. If incoming.BindPassword == "" but a password is already saved, the old one is preserved (matches the "leave empty to keep current" UX).

func (*Service) Enabled

func (s *Service) Enabled() bool

func (*Service) InspectUser added in v0.8.430

func (s *Service) InspectUser(ctx context.Context, username string) (*InspectResult, error)

InspectUser finds one user (same filter the login path uses) and returns all readable attributes.

func (*Service) LoadPersisted

func (s *Service) LoadPersisted(ctx context.Context, store SettingsStore) error

func (*Service) SavePersisted

func (s *Service) SavePersisted(ctx context.Context, store SettingsStore, c Config) error

func (*Service) Search

func (s *Service) Search(ctx context.Context, query string, limit int) ([]LDAPUser, error)

Search looks up users matching `query` (substring on username, email or displayName). Used by the admin "pick a user to provision" flow. Returns at most `limit` entries.

func (*Service) Snapshot

func (s *Service) Snapshot() Config

Snapshot returns a sanitized copy (no plain bind password).

func (*Service) StartConfigRefresh added in v0.5.324

func (s *Service) StartConfigRefresh(ctx context.Context, store SettingsStore, interval time.Duration)

StartConfigRefresh — v0.5.324. Background goroutine that re-reads the persisted LDAP config from the shared store every `interval`. Closes the multi-pod gap where one pod wrote new settings but other pods kept serving stale in-memory cfg until restart. interval ≤ 0 → 30s.

func (*Service) TestConnection

func (s *Service) TestConnection(ctx context.Context, override *Config) error

TestConnection establishes + service-binds + closes. Returns nil on success so the UI's "Test connection" button can flip green.

type SettingsStore

type SettingsStore interface {
	GetSetting(ctx context.Context, key string) ([]byte, error)
	PutSetting(ctx context.Context, key string, value []byte) error
}

type Snapshot added in v0.8.526

type Snapshot struct {
	Groups     map[string]Group    `json:"-"`
	UserGroups map[string][]string `json:"-"`
	SyncedAt   time.Time           `json:"syncedAt"`
	Stats      SyncStats           `json:"stats"`
}

Snapshot is the published authz view. Groups is keyed by UID; UserGroups maps a normalized identity alias (lowercase sAMAccountName / UPN / UPN local-part / mail) to the group UIDs it belongs to. After a CH hydrate only the sAMAccountName alias is reconstructable (the richer aliases live only in the live-sync path — the durable join key is the lowercase sAMAccountName == users.ldap_username).

type SyncEngine added in v0.8.526

type SyncEngine struct {
	// contains filtered or unexported fields
}

SyncEngine owns the snapshot pointer + directory/persistence wiring.

func NewSyncEngine added in v0.8.526

func NewSyncEngine(svc *Service, store GroupStore) *SyncEngine

NewSyncEngine wires the engine. Instrument construction errors are logged, not fatal (they don't happen with a real or noop meter).

func (*SyncEngine) Enabled added in v0.8.526

func (e *SyncEngine) Enabled() bool

Enabled reports whether group sync is configured to run.

func (*SyncEngine) Hydrate added in v0.8.526

func (e *SyncEngine) Hydrate(ctx context.Context) error

Hydrate rebuilds the snapshot from ClickHouse (boot + periodic follower refresh). Only the sAMAccountName alias is reconstructable from storage; that is the durable authz join key.

func (*SyncEngine) Preview added in v0.8.526

func (e *SyncEngine) Preview(ctx context.Context) (*PreviewResult, error)

Preview runs a bounded, read-only dry-run: enumerate in-scope groups, sample the first few + their first members, and compute the identity overlap — without persisting or publishing. The diagnostic surface for the sAMAccountName↔email early-warning before committing a real sync.

func (*SyncEngine) Snapshot added in v0.8.526

func (e *SyncEngine) Snapshot() *Snapshot

Snapshot returns the current published snapshot (nil = never synced / hydrated). Cheap atomic load — safe on the login hot path.

func (*SyncEngine) StartHydrateRefresh added in v0.8.526

func (e *SyncEngine) StartHydrateRefresh(ctx context.Context, interval time.Duration)

StartHydrateRefresh re-hydrates the snapshot from CH every `interval` on ALL pods, so api/follower pods track what the leader wrote. interval ≤ 0 → 30s. Mirrors Service.StartConfigRefresh.

func (*SyncEngine) Summary added in v0.8.526

func (e *SyncEngine) Summary() SyncSummary

Summary projects the current snapshot for the admin status card. Reads the in-memory pointer only — no directory or CH I/O.

func (*SyncEngine) Sync added in v0.8.526

func (e *SyncEngine) Sync(ctx context.Context) (*Snapshot, error)

Sync runs one full round: enumerate in-scope groups → chain-search each group's members → persist (full set + tombstones) → compute identity overlap → atomically publish. Returns without touching the snapshot on ANY error (fail-stale).

func (*SyncEngine) SyncInterval added in v0.8.526

func (e *SyncEngine) SyncInterval() time.Duration

SyncInterval is the configured tick period (default 30m).

func (*SyncEngine) SyncTimeout added in v0.8.526

func (e *SyncEngine) SyncTimeout() time.Duration

SyncTimeout is the configured per-round wall-clock cap (default 60s).

type SyncStats added in v0.8.526

type SyncStats struct {
	Groups     int     `json:"groups"`
	Users      int     `json:"users"`      // distinct member identities across all groups
	Pages      int     `json:"pages"`      // LDAP search calls issued this round
	Truncated  int     `json:"truncated"`  // groups clamped at MaxGroupMembers
	Tombstoned int     `json:"tombstoned"` // groups gone from the directory this round
	Matched    int     `json:"matched"`    // aliases that resolved to a users-table identity
	TotalAlias int     `json:"totalAlias"` // distinct alias keys
	MatchRatio float64 `json:"matchRatio"` // Matched / TotalAlias
	DurationMs int64   `json:"durationMs"`
}

SyncStats is the per-round telemetry surfaced on the status card + /admin/stats.

type SyncSummary added in v0.8.526

type SyncSummary struct {
	Configured bool           `json:"configured"` // ldap enabled + host set
	Enabled    bool           `json:"enabled"`    // group sync turned on
	Interval   string         `json:"interval"`
	Synced     bool           `json:"synced"` // a snapshot exists
	SyncedAt   *time.Time     `json:"syncedAt,omitempty"`
	Groups     []GroupSummary `json:"groups"`
	Stats      SyncStats      `json:"stats"`
}

SyncSummary is the GET /api/admin/ldap/groupsync payload.

type Syncer added in v0.8.526

type Syncer interface {
	Sync(ctx context.Context) (*Snapshot, error)
}

Syncer is the audit §2 contract: one round that returns the fresh snapshot or an error (never a partial). *SyncEngine satisfies it.

type TeamCandidate added in v0.8.523

type TeamCandidate struct {
	// Pattern — TeamRegex'e yazılacak desen; "" = ham değer (regex yok).
	Pattern string `json:"pattern"`
	// Extracted — deseni BU kullanıcının değerine uygulayınca çıkan.
	Extracted string `json:"extracted"`
	// Label — insan-okur kısa açıklama (UI tooltip'i).
	Label string `json:"label"`
}

team_candidates.go — v0.8.523 (operatör isteği: "auto-discover et ya da UI'dan seçmeme izin ver"). Inspect edilen kullanıcının attribute değerlerine bilinen bileşik-desen kütüphanesini uygular; boş olmayan ve birbirinden farklı sonuç veren adayları döndürür. Operatör UI'da ÇIKAN DEĞERİ görüp tıklar — regex bilgisi gerekmez; seçim TeamAttribute+TeamRegex olarak kaydedilir. Çıkan team değeri sonra katalog sy-team/ug-team eşleşmesi ve e-posta yönlendirmesinde kullanılacağı için adaylar TRİMLİ/temiz üretilir.

func TeamCandidates added in v0.8.523

func TeamCandidates(value string) []TeamCandidate

TeamCandidates generates the click-to-pick extraction candidates for one attribute value. Pure — tablo-testli.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL