C1. A membership of an organization may be admin as well as member and manager. An admin sees /org as a manager does and changes the organization from it: its teams and projects, who of the approved accounts is in them, its members' roles, and the keys spent in its projects. Every id a form names is checked against the organization; limits, accounts, the organization's name and the cluster stay with the cluster's staff, and an admin cannot take their own role away. A group from OIDC, SCIM or a directory no longer rewrites a membership it names as a plain member at every sign-in, which took a manager's role away (EnsureMembership). Pages draw a form only for someone who may send it: Nav.May asks the staff rule staffMay keeps, and a page whose forms a person may not send gets no form token. A viewer sees the people, limits and mirror pages without forms; an operator the mirror's. Forms are compact: labels close to their fields, fields as wide as what goes in them, smaller buttons, and the short forms on one line. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
396 lines
12 KiB
Go
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
|
|
EnsureMembership(ctx context.Context, user string, scope store.Scope, id, 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.EnsureMembership(ctx, u.ID, scope, sid, 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[:])
|
|
}
|