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 ¶
- type AuthResult
- type Config
- type Group
- type GroupRoleMapping
- type GroupStore
- type GroupSummary
- type GroupSyncConfig
- type InspectResult
- type LDAPUser
- type PreviewGroup
- type PreviewResult
- type Service
- func (s *Service) Authenticate(ctx context.Context, username, password string) (*AuthResult, error)
- func (s *Service) Configure(incoming Config)
- func (s *Service) Enabled() bool
- func (s *Service) InspectUser(ctx context.Context, username string) (*InspectResult, error)
- func (s *Service) LoadPersisted(ctx context.Context, store SettingsStore) error
- func (s *Service) SavePersisted(ctx context.Context, store SettingsStore, c Config) error
- func (s *Service) Search(ctx context.Context, query string, limit int) ([]LDAPUser, error)
- func (s *Service) Snapshot() Config
- func (s *Service) StartConfigRefresh(ctx context.Context, store SettingsStore, interval time.Duration)
- func (s *Service) TestConnection(ctx context.Context, override *Config) error
- type SettingsStore
- type Snapshot
- type SyncEngine
- func (e *SyncEngine) Enabled() bool
- func (e *SyncEngine) Hydrate(ctx context.Context) error
- func (e *SyncEngine) Preview(ctx context.Context) (*PreviewResult, error)
- func (e *SyncEngine) Snapshot() *Snapshot
- func (e *SyncEngine) StartHydrateRefresh(ctx context.Context, interval time.Duration)
- func (e *SyncEngine) Summary() SyncSummary
- func (e *SyncEngine) Sync(ctx context.Context) (*Snapshot, error)
- func (e *SyncEngine) SyncInterval() time.Duration
- func (e *SyncEngine) SyncTimeout() time.Duration
- type SyncStats
- type SyncSummary
- type Syncer
- type TeamCandidate
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.
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 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:
- Service-bind (admin lookup credentials).
- Find the user by username (or email — `username` may be either, the configured UserSearchFilter handles it).
- Re-bind with the user's DN + entered password — that's the actual credential check.
- 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 (*Service) Authenticate ¶
func (*Service) Configure ¶
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) InspectUser ¶ added in v0.8.430
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 (*Service) Search ¶
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) 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.
type SettingsStore ¶
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
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.