Files
llmgw/internal/directory/directory.go
T
Ilker ManapandClaude Opus 5.5 f27d722111 People sign in with their Active Directory account
auth.ldap checks a username and password against the domain controllers
over LDAPS or StartTLS: found with a service account, checked by binding
as the person. Groups, nested ones included, give staff roles and places
in the tree. Accounts are tied to the objectGUID; the leader reads the
directory every 15 minutes, suspends whom it disabled or removed and
reinstates them after, and refuses a sync that would suspend most.

Entra ID sends no groups for someone in more than 200, only where to
fetch them; that was read as none and took an administrator's role away.
It is now left as it was, and logged. An account made by SCIM or OIDC
can be reinstated again.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-18 10:59:50 +02:00

396 lines
12 KiB
Go

// Package directory keeps accounts in step with an outside directory —
// Active Directory over LDAP — at sign-in and by a periodic sync: made for
// people the directory vouches for, given the staff role and the places in
// the tree their groups map to, and suspended when the directory disables or
// loses them.
package directory
import (
"context"
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"slices"
"strings"
"time"
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
)
// Identity is a person as the directory has them.
type Identity struct {
// ID never changes for the person: an AD objectGUID.
ID string
DN string
Email string
Name string
// Groups are the person's groups, nested ones included, each by its CN
// and by its DN, so a mapping may name either.
Groups []string
// Disabled is an account the directory has turned off.
Disabled bool
}
// Mapping is what a person's groups give them: a staff role and places in
// the tree. The same shape as auth.oidc's.
type Mapping struct {
Roles map[string][]string
Memberships map[string]string
}
// Role is the highest staff role the groups give, "" when none.
func (m Mapping) Role(groups []string) string {
for _, r := range []string{store.RoleAdmin, store.RoleOperator, store.RoleBilling, store.RoleViewer} {
if slices.ContainsFunc(m.Roles[r], func(g string) bool { return hasGroup(groups, g) }) {
return r
}
}
return ""
}
// Places are the places in the tree the groups give, each once.
func (m Mapping) Places(groups []string) []string {
var out []string
for g, where := range m.Memberships {
if hasGroup(groups, g) && !slices.Contains(out, where) {
out = append(out, where)
}
}
slices.Sort(out)
return out
}
// Groups are every group the mapping names.
func (m Mapping) Groups() []string {
var out []string
add := func(g string) {
if !slices.ContainsFunc(out, func(o string) bool { return strings.EqualFold(o, g) }) {
out = append(out, g)
}
}
for _, gs := range m.Roles {
for _, g := range gs {
add(g)
}
}
for g := range m.Memberships {
add(g)
}
slices.Sort(out)
return out
}
// hasGroup matches a group's CN or DN as AD does: case aside.
func hasGroup(groups []string, g string) bool {
return slices.ContainsFunc(groups, func(have string) bool { return strings.EqualFold(have, g) })
}
// Store is what keeping accounts needs of the store.
type Store interface {
UserByEmail(ctx context.Context, email string) (store.User, error)
UserByID(ctx context.Context, id string) (store.User, error)
CreateUser(ctx context.Context, u store.User) error
SetStaffRole(ctx context.Context, id, role string) error
SetUserState(ctx context.Context, id string, state store.UserState) error
DeleteUserSessions(ctx context.Context, userID string) error
AddMembership(ctx context.Context, user string, scope store.Scope, id, role, by string, at time.Time) error
RemoveMembership(ctx context.Context, userID string, scope store.Scope, scopeID string, at time.Time) (int, error)
MembershipsOf(ctx context.Context, userID string) ([]store.Membership, error)
AllOrgs(ctx context.Context) ([]store.Org, error)
AllTeams(ctx context.Context) ([]store.TeamPath, error)
AllProjects(ctx context.Context) ([]store.ProjectPath, error)
DirectoryUser(ctx context.Context, directory, externalID string) (store.DirectoryLink, error)
LinkDirectory(ctx context.Context, userID, directory, externalID string, at time.Time) error
DirectoryLinks(ctx context.Context, directory string) ([]store.DirectoryLink, error)
SetSyncSuspended(ctx context.Context, userID string, suspended bool, at time.Time) error
}
// Keeper keeps one directory's accounts.
type Keeper struct {
St Store
// Name is the directory's, as links and memberships record it: "ldap".
Name string
Mapping Mapping
CreateAccounts bool
Now func() time.Time
}
// The ways a person the directory vouches for is still refused.
var (
ErrNoAccount = errors.New("no account here, and accounts are not made for the directory's people")
ErrSuspended = errors.New("the account is suspended here")
ErrNotActive = errors.New("the account is not active here")
ErrNoEmail = errors.New("the directory has no mail for this person")
// ErrImplausible is a sync that would suspend too many at once: done
// nothing, and said.
ErrImplausible = errors.New("the directory's answer is implausible; nobody was suspended")
)
func (k *Keeper) now() time.Time {
if k.Now != nil {
return k.Now()
}
return time.Now()
}
// SignIn is the account for a person the directory has just vouched for:
// found by their directory id, else their mail, else made; given what their
// groups map to; refused when it may not sign in.
func (k *Keeper) SignIn(ctx context.Context, id Identity) (store.User, error) {
u, link, err := k.account(ctx, id)
if err != nil {
return store.User{}, err
}
// The directory has them enabled, or the bind would have failed: an
// account the sync suspended for being disabled there is theirs again.
if u.State == store.UserSuspended && link.SuspendedBySync {
if u, err = k.reinstate(ctx, u); err != nil {
return store.User{}, err
}
}
switch {
case u.State == store.UserSuspended:
return store.User{}, ErrSuspended
case !u.CanSignIn():
return store.User{}, fmt.Errorf("%w: %s", ErrNotActive, u.State)
}
if err := k.apply(ctx, &u, id, false); err != nil {
return store.User{}, err
}
return u, nil
}
// account finds or makes the account and links it to the directory.
func (k *Keeper) account(ctx context.Context, id Identity) (store.User, store.DirectoryLink, error) {
link, err := k.St.DirectoryUser(ctx, k.Name, id.ID)
switch {
case err == nil:
u, err := k.St.UserByID(ctx, link.UserID)
return u, link, err
case !errors.Is(err, store.ErrNotFound):
return store.User{}, link, err
}
email := store.NormalizeEmail(id.Email)
if email == "" {
return store.User{}, link, ErrNoEmail
}
u, err := k.St.UserByEmail(ctx, email)
switch {
case errors.Is(err, store.ErrNotFound) && k.CreateAccounts:
name := id.Name
if name == "" {
name = email
}
u = store.User{ID: "usr-" + randomID(), Email: email, Name: name,
// No password works here: the directory's is checked.
PasswordHash: "!" + k.Name, State: store.UserActive, CreatedAt: k.now()}
if err := k.St.CreateUser(ctx, u); err != nil {
return store.User{}, link, err
}
case errors.Is(err, store.ErrNotFound):
return store.User{}, link, fmt.Errorf("%w: %s", ErrNoAccount, email)
case err != nil:
return store.User{}, link, err
}
if err := k.St.LinkDirectory(ctx, u.ID, k.Name, id.ID, k.now()); err != nil {
return store.User{}, link, err
}
return u, store.DirectoryLink{UserID: u.ID, Directory: k.Name, ExternalID: id.ID}, nil
}
// apply gives the account the role and places its groups map to. With
// remove, a place this directory gave that the groups no longer do is taken
// away; one given otherwise is never touched.
func (k *Keeper) apply(ctx context.Context, u *store.User, id Identity, remove bool) error {
if len(k.Mapping.Roles) > 0 {
if role := k.Mapping.Role(id.Groups); role != u.StaffRole() {
if err := k.St.SetStaffRole(ctx, u.ID, role); err != nil {
return err
}
u.IsAdmin, u.Role = role == store.RoleAdmin, role
if role == store.RoleAdmin {
u.Role = ""
}
}
}
want := map[string]store.Scope{}
for _, where := range k.Mapping.Places(id.Groups) {
scope, sid, err := k.place(ctx, where)
if err != nil {
continue // a mapping to a place the tree lacks: said by Sync
}
want[string(scope)+"/"+sid] = scope
if err := k.St.AddMembership(ctx, u.ID, scope, sid, store.RoleMember, k.Name, k.now()); err != nil {
return err
}
}
if !remove {
return nil
}
have, err := k.St.MembershipsOf(ctx, u.ID)
if err != nil {
return err
}
for _, m := range have {
if m.AddedBy != k.Name {
continue
}
if _, ok := want[string(m.Scope)+"/"+m.ScopeID]; ok {
continue
}
if _, err := k.St.RemoveMembership(ctx, u.ID, m.Scope, m.ScopeID, k.now()); err != nil {
return err
}
}
return nil
}
func (k *Keeper) reinstate(ctx context.Context, u store.User) (store.User, error) {
if err := k.St.SetUserState(ctx, u.ID, store.UserActive); err != nil {
return u, err
}
if err := k.St.SetSyncSuspended(ctx, u.ID, false, k.now()); err != nil {
return u, err
}
u.State = store.UserActive
return u, nil
}
// SyncReport is what a sync did.
type SyncReport struct {
Seen, Updated, Suspended, Reinstated int
// Unmapped are mappings to places the tree does not have.
Unmapped []string
}
// Sync brings every account the directory keeps in step with what it says
// now: people disabled or gone there are suspended here (their sessions
// ended; their keys refuse, as a suspended owner's do), those enabled again
// that the sync had suspended are reinstated, and everyone's role and places
// follow their groups. People the directory has that never signed in here
// are left until they do.
func (k *Keeper) Sync(ctx context.Context, people []Identity) (SyncReport, error) {
var rep SyncReport
rep.Unmapped = k.unmapped(ctx)
byID := make(map[string]Identity, len(people))
for _, p := range people {
byID[p.ID] = p
}
links, err := k.St.DirectoryLinks(ctx, k.Name)
if err != nil {
return rep, err
}
// A directory that answers with nobody, or with half the people missing,
// is far likelier a wrong base, a filter or a bind account's lost rights
// than half the company leaving at once. Nothing is suspended for it.
gone := 0
for _, l := range links {
if p, there := byID[l.ExternalID]; !there || p.Disabled {
gone++
}
}
if len(links) > 0 && (len(people) == 0 || (gone >= 3 && gone*2 > len(links))) {
return rep, fmt.Errorf("%w: %d of the %d accounts it keeps would be suspended", ErrImplausible, gone, len(links))
}
for _, l := range links {
rep.Seen++
u, err := k.St.UserByID(ctx, l.UserID)
if err != nil {
continue
}
p, there := byID[l.ExternalID]
switch {
case !there || p.Disabled:
if u.State == store.UserActive {
if err := k.St.SetUserState(ctx, u.ID, store.UserSuspended); err != nil {
return rep, err
}
if err := k.St.DeleteUserSessions(ctx, u.ID); err != nil {
return rep, err
}
if err := k.St.SetSyncSuspended(ctx, u.ID, true, k.now()); err != nil {
return rep, err
}
rep.Suspended++
}
default:
if u.State == store.UserSuspended && l.SuspendedBySync {
if u, err = k.reinstate(ctx, u); err != nil {
return rep, err
}
rep.Reinstated++
}
if u.State != store.UserActive {
continue
}
before := u.StaffRole()
if err := k.apply(ctx, &u, p, true); err != nil {
return rep, err
}
if u.StaffRole() != before {
rep.Updated++
}
}
}
return rep, nil
}
// unmapped are the mapping's places the tree does not have.
func (k *Keeper) unmapped(ctx context.Context) []string {
var out []string
for _, where := range k.Mapping.Memberships {
if _, _, err := k.place(ctx, where); err != nil && !slices.Contains(out, where) {
out = append(out, where)
}
}
slices.Sort(out)
return out
}
// place finds org:NAME, team:ORG/TEAM, project:ORG/TEAM/PROJECT, as
// auth.oidc's mapping does.
func (k *Keeper) place(ctx context.Context, where string) (store.Scope, string, error) {
kind, path, _ := strings.Cut(where, ":")
norm := func(s string) string {
parts := strings.Split(s, "/")
for i := range parts {
parts[i] = strings.TrimSpace(parts[i])
}
return strings.Join(parts, "/")
}
same := func(a string) bool { return strings.EqualFold(norm(a), norm(path)) }
switch kind {
case "org":
orgs, err := k.St.AllOrgs(ctx)
for _, o := range orgs {
if same(o.Name) {
return store.ScopeOrg, o.ID, nil
}
}
return "", "", errors.Join(err, errors.New("no such organization"))
case "team":
teams, err := k.St.AllTeams(ctx)
for _, t := range teams {
if same(t.OrgName + "/" + t.Name) {
return store.ScopeTeam, t.ID, nil
}
}
return "", "", errors.Join(err, errors.New("no such team"))
default:
projects, err := k.St.AllProjects(ctx)
for _, p := range projects {
if same(p.Path) {
return store.ScopeProject, p.ID, nil
}
}
return "", "", errors.Join(err, errors.New("no such project"))
}
}
func randomID() string {
var b [8]byte
_, _ = rand.Read(b[:])
return hex.EncodeToString(b[:])
}