From 6e9218bf4321461c9b14d330b1bb4698083e07c8 Mon Sep 17 00:00:00 2001 From: ale <ale@incal.net> Date: Tue, 20 Mar 2018 20:06:43 +0000 Subject: [PATCH] Add code for writing changed resources to LDAP --- backend/diff.go | 82 ++++++++++++++++++ backend/model.go | 187 ++++++++++++++++++++++++++++++++++++++++++ backend/model_test.go | 79 ++++++++++++++++++ server/server.go | 48 ++++++++++- types.go | 41 +++++++++ 5 files changed, 434 insertions(+), 3 deletions(-) create mode 100644 backend/diff.go create mode 100644 backend/model_test.go diff --git a/backend/diff.go b/backend/diff.go new file mode 100644 index 00000000..b0c3d00c --- /dev/null +++ b/backend/diff.go @@ -0,0 +1,82 @@ +package backend + +// Implementing read-modify-update cycles with the LDAP backend. +// +// How can we track modifications to Resources in a backend-independent way? One +// method could be to expose specific methods on the Backend interface for every +// change we might want to make: EmailAddAlias, UserSetPassword, etc., but this +// scales very poorly with the number of attributes and operations. Instead, we +// add methods to de-serialize Resources to LDAP objects (or rather, sequences +// of attributes), so that we can compute differences with the original objects +// and issue the appropriate incremental ModifyRequest. +// +// To do this, GetResource keeps around a copy of the original resource, so when +// calling UpdateResource, the two serializations are compared to obtain a +// ModifyRequest: this way, other LDAP attributes that might be present in the +// database (but are not managed by this system) are left untouched on existing +// objects. Attributes explicitly unset (set to the nil value) in the Resource +// will be deleted from LDAP. + +import ( + ldap "gopkg.in/ldap.v2" + + "git.autistici.org/ai3/accountserver" +) + +func partialAttributesToMap(attrs []ldap.PartialAttribute) map[string]ldap.PartialAttribute { + m := make(map[string]ldap.PartialAttribute) + for _, attr := range attrs { + m[attr.Type] = attr + } + return m +} + +func partialAttributeEquals(a, b ldap.PartialAttribute) bool { + // We never sort lists, so we can compare them element-wise. + if len(a.Vals) != len(b.Vals) { + return false + } + for i := 0; i < len(a.Vals); i++ { + if a.Vals[i] != b.Vals[i] { + return false + } + } + return true +} + +// Populate the ldap.ModifyRequest, returns false if unchanged. +func partialAttributeMapDiff(mod *ldap.ModifyRequest, a, b map[string]ldap.PartialAttribute) bool { + var changed bool + for bkey, battr := range b { + aattr, ok := a[bkey] + if !ok { + mod.Add(battr.Type, battr.Vals) + changed = true + } else if battr.Vals == nil { + mod.Delete(battr.Type, battr.Vals) + changed = true + } else if !partialAttributeEquals(aattr, battr) { + mod.Replace(battr.Type, battr.Vals) + changed = true + } + } + return changed +} + +func diffResources(mod *ldap.ModifyRequest, a, b *accountserver.Resource) bool { + return partialAttributeMapDiff( + mod, + partialAttributesToMap(resourceToLDAP(a)), + partialAttributesToMap(resourceToLDAP(b)), + ) +} + +// Assemble a ldap.ModifyRequest object by checking differences in two +// Resources objects. If the objects are identical, nil is returned. +func createModifyRequest(dn string, a, b *accountserver.Resource) *ldap.ModifyRequest { + mod := ldap.NewModifyRequest(dn) + if diffResources(mod, a, b) { + return mod + } + return nil +} diff --git a/backend/model.go b/backend/model.go index fb962193..793186e7 100644 --- a/backend/model.go +++ b/backend/model.go @@ -17,6 +17,8 @@ import ( // testing. type ldapConn interface { Search(context.Context, *ldap.SearchRequest) (*ldap.SearchResult, error) + Add(context.Context, *ldap.AddRequest) error + Modify(context.Context, *ldap.ModifyRequest) error Close() } @@ -25,6 +27,7 @@ type LDAPBackend struct { conn ldapConn userQuery *queryConfig userResourceQueries []*queryConfig + resourceQueries map[string]*queryConfig } // NewLDAPBackend initializes an LDAPBackend object with the given LDAP @@ -49,6 +52,33 @@ func NewLDAPBackend(pool *ldaputil.ConnectionPool, base string) *LDAPBackend { Scope: "one", }), }, + resourceQueries: map[string]*queryConfig{ + accountserver.ResourceTypeEmail: mustCompileQueryConfig(&queryConfig{ + Base: "mail=${resource},uid=${user},ou=People," + base, + Filter: "(objectClass=virtualMailUser)", + Scope: "base", + }), + accountserver.ResourceTypeWebsite: mustCompileQueryConfig(&queryConfig{ + Base: "uid=${user},ou=People," + base, + Filter: "(|(&(objectClass=subSite)(alias=${resource}))(&(objectClass=virtualHost)(cn=${resource})))", + Scope: "one", + }), + accountserver.ResourceTypeDAV: mustCompileQueryConfig(&queryConfig{ + Base: "uid=${user},ou=People," + base, + Filter: "(&(objectClass=ftpAccount)(ftpname=${resource}))", + Scope: "sub", + }), + accountserver.ResourceTypeDatabase: mustCompileQueryConfig(&queryConfig{ + Base: "uid=${user},ou=People," + base, + Filter: "(&(objectClass=dbMysql)(dbname=${resource}))", + Scope: "sub", + }), + accountserver.ResourceTypeMailingList: mustCompileQueryConfig(&queryConfig{ + Base: "ou=Lists," + base, + Filter: "(&(objectClass=mailingList)(listName=${resource}))", + Scope: "one", + }), + }, } } @@ -115,6 +145,13 @@ func s2b(s string) bool { } } +func b2s(b bool) string { + if b { + return "yes" + } + return "no" +} + func newResourceFromLDAP(entry *ldap.Entry, resourceType, nameAttr string) *accountserver.Resource { name := entry.GetAttributeValue(nameAttr) return &accountserver.Resource{ @@ -127,6 +164,44 @@ func newResourceFromLDAP(entry *ldap.Entry, resourceType, nameAttr string) *acco } } +// Convert a string to a []string with a single item, or nil if the +// string is empty. Useful for optional single-valued LDAP attributes. +func s2l(s string) []string { + if s == "" { + return nil + } + return []string{s} +} + +func resourceToLDAP(r *accountserver.Resource) []ldap.PartialAttribute { + // Assemble LDAP attributes for this resource. Use a type-specific + // method to get attributes, then add the resource-generic ones if + // necessary. Note that it is very important that the "objectClass" + // attribute is returned first, or ldap.Add will fail. + + var attrs []ldap.PartialAttribute + switch r.Type { + case accountserver.ResourceTypeEmail: + attrs = emailResourceToLDAP(r) + case accountserver.ResourceTypeWebsite: + attrs = websiteResourceToLDAP(r) + case accountserver.ResourceTypeDAV: + attrs = webDAVResourceToLDAP(r) + case accountserver.ResourceTypeDatabase: + attrs = databaseResourceToLDAP(r) + case accountserver.ResourceTypeMailingList: + attrs = mailingListResourceToLDAP(r) + } + + attrs = append(attrs, []ldap.PartialAttribute{ + {Type: "status", Vals: s2l(r.Status)}, + {Type: "host", Vals: s2l(r.Shard)}, + {Type: "originalHost", Vals: s2l(r.OriginalShard)}, + }...) + + return attrs +} + func newEmailResource(entry *ldap.Entry) (*accountserver.Resource, error) { r := newResourceFromLDAP(entry, accountserver.ResourceTypeEmail, "mail") r.Email = &accountserver.Email{ @@ -136,6 +211,15 @@ func newEmailResource(entry *ldap.Entry) (*accountserver.Resource, error) { return r, nil } +func emailResourceToLDAP(r *accountserver.Resource) []ldap.PartialAttribute { + return []ldap.PartialAttribute{ + {Type: "objectClass", Vals: []string{"top", "virtualMailUser"}}, + {Type: "mail", Vals: s2l(r.Name)}, + {Type: "mailAlternateAddr", Vals: r.Email.Aliases}, + {Type: "mailMessageStore", Vals: s2l(r.Email.Maildir)}, + } +} + func newMailingListResource(entry *ldap.Entry) (*accountserver.Resource, error) { r := newResourceFromLDAP(entry, accountserver.ResourceTypeMailingList, "listName") r.List = &accountserver.MailingList{ @@ -145,6 +229,15 @@ func newMailingListResource(entry *ldap.Entry) (*accountserver.Resource, error) return r, nil } +func mailingListResourceToLDAP(r *accountserver.Resource) []ldap.PartialAttribute { + return []ldap.PartialAttribute{ + {Type: "objectClass", Vals: []string{"top", "mailingList"}}, + {Type: "listName", Vals: s2l(r.Name)}, + {Type: "public", Vals: s2l(b2s(r.List.Public))}, + {Type: "listOwner", Vals: r.List.Admins}, + } +} + func newWebDAVResource(entry *ldap.Entry) (*accountserver.Resource, error) { r := newResourceFromLDAP(entry, accountserver.ResourceTypeDAV, "ftpname") r.DAV = &accountserver.WebDAV{ @@ -153,6 +246,14 @@ func newWebDAVResource(entry *ldap.Entry) (*accountserver.Resource, error) { return r, nil } +func webDAVResourceToLDAP(r *accountserver.Resource) []ldap.PartialAttribute { + return []ldap.PartialAttribute{ + {Type: "objectClass", Vals: []string{"top", "person", "posixAccount", "shadowAccount", "organizationalPerson", "inetOrgPerson", "ftpAccount"}}, + {Type: "ftpname", Vals: s2l(r.Name)}, + {Type: "homeDirectory", Vals: s2l(r.DAV.Homedir)}, + } +} + func newWebsiteResource(entry *ldap.Entry) (*accountserver.Resource, error) { var r *accountserver.Resource if isObjectClass(entry, "subSite") { @@ -174,6 +275,25 @@ func newWebsiteResource(entry *ldap.Entry) (*accountserver.Resource, error) { return r, nil } +func websiteResourceToLDAP(r *accountserver.Resource) []ldap.PartialAttribute { + // Subsites and vhosts have a different RDN. + var mainRDN, mainOC string + if strings.Contains(r.Website.DisplayName, "/") { + mainRDN = "alias" + mainOC = "subSite" + } else { + mainRDN = "cn" + mainOC = "virtualHost" + } + return []ldap.PartialAttribute{ + {Type: "objectClass", Vals: []string{"top", mainOC}}, + {Type: mainRDN, Vals: s2l(r.Name)}, + {Type: "option", Vals: r.Website.Options}, + {Type: "documentRoot", Vals: s2l(r.Website.DocumentRoot)}, + {Type: "acceptMail", Vals: s2l(b2s(r.Website.AcceptMail))}, + } +} + func newDatabaseResource(entry *ldap.Entry) (*accountserver.Resource, error) { r := newResourceFromLDAP(entry, accountserver.ResourceTypeDatabase, "dbname") r.Database = &accountserver.Database{ @@ -191,6 +311,15 @@ func newDatabaseResource(entry *ldap.Entry) (*accountserver.Resource, error) { return r, nil } +func databaseResourceToLDAP(r *accountserver.Resource) []ldap.PartialAttribute { + return []ldap.PartialAttribute{ + {Type: "objectClass", Vals: []string{"top", "dbMysql"}}, + {Type: "dbname", Vals: s2l(r.Name)}, + {Type: "dbuser", Vals: s2l(r.Database.DBUser)}, + {Type: "clearPassword", Vals: s2l(r.Database.CleartextPassword)}, + } +} + func newUser(entry *ldap.Entry) (*accountserver.User, error) { user := &accountserver.User{ Name: entry.GetAttributeValue("uid"), @@ -251,6 +380,64 @@ func (b *LDAPBackend) GetUser(ctx context.Context, username string) (*accountser return user, nil } +func parseResourceID(resourceID string) (string, string) { + parts := strings.SplitN(resourceID, "/", 2) + return parts[0], parts[1] +} + +// GetResource returns a ResourceWrapper, as part of a read-modify-update transaction. +func (b *LDAPBackend) GetResource(ctx context.Context, username, resourceID string) (*accountserver.Resource, error) { + resourceType, resourceName := parseResourceID(resourceID) + query, ok := b.resourceQueries[resourceType] + if !ok { + return nil, errors.New("unsupported resource type") + } + + result, err := b.conn.Search(ctx, query.searchRequest(map[string]string{ + "user": username, + "resource": resourceName, + "type": resourceType, + }, nil)) + if err != nil { + if ldap.IsErrorWithCode(err, ldap.LDAPResultNoSuchObject) { + return nil, nil + } + return nil, err + } + + r, err := parseLdapResource(result.Entries[0]) + if err != nil { + return nil, err + } + + r.SetBackendHandle(&ldapObjectData{ + dn: result.Entries[0].DN, + original: r.Copy(), + }) + + return r, nil +} + +// UpdateResource updates a LDAP-backed resource that was obtained by a previous GetResource call. +func (b *LDAPBackend) UpdateResource(ctx context.Context, username string, r *accountserver.Resource) error { + lo, ok := r.GetBackendHandle().(*ldapObjectData) + if !ok || lo == nil { + return errors.New("resource did not come from GetResource") + } + + modRequest := createModifyRequest(lo.dn, lo.original, r) + if modRequest == nil { + return nil + } + + return b.conn.Modify(ctx, modRequest) +} + +type ldapObjectData struct { + dn string + original *accountserver.Resource +} + func parseLdapResource(entry *ldap.Entry) (*accountserver.Resource, error) { switch { case isObjectClass(entry, "virtualMailUser"): diff --git a/backend/model_test.go b/backend/model_test.go new file mode 100644 index 00000000..66417042 --- /dev/null +++ b/backend/model_test.go @@ -0,0 +1,79 @@ +package backend + +import ( + "reflect" + "testing" + + "git.autistici.org/ai3/accountserver" + ldap "gopkg.in/ldap.v2" +) + +func TestEmailResource_FromLDAP(t *testing.T) { + entry := ldap.NewEntry( + "mail=test@investici.org,ou=People,dc=investici,dc=org,o=Anarchy", + map[string][]string{ + "objectClass": []string{"top", "virtualMailUser"}, + "mail": []string{"test@investici.org"}, + "status": []string{"active"}, + "host": []string{"host1"}, + "originalHost": []string{"host1"}, + "mailAlternateAddr": []string{"test2@investici.org", "test3@investici.org"}, + "mailMessageStore": []string{"test/store"}, + }, + ) + + r, err := parseLdapResource(entry) + if err != nil { + t.Fatal(err) + } + + expected := &accountserver.Resource{ + ID: "email/test@investici.org", + Name: "test@investici.org", + Type: accountserver.ResourceTypeEmail, + Status: "active", + Shard: "host1", + OriginalShard: "host1", + Email: &accountserver.Email{ + Aliases: []string{"test2@investici.org", "test3@investici.org"}, + Maildir: "test/store", + }, + } + if !reflect.DeepEqual(r, expected) { + t.Fatalf("bad result: got %+v, expected %+v", r, expected) + } +} + +func TestEmailResource_Diff(t *testing.T) { + entry := ldap.NewEntry( + "mail=test@investici.org,ou=People,dc=investici,dc=org,o=Anarchy", + map[string][]string{ + "objectClass": []string{"top", "virtualMailUser"}, + "mail": []string{"test@investici.org"}, + "status": []string{"active"}, + "host": []string{"host1"}, + "originalHost": []string{"host1"}, + "mailAlternateAddr": []string{"test2@investici.org", "test3@investici.org"}, + "mailMessageStore": []string{"test/store"}, + }, + ) + + r, err := parseLdapResource(entry) + if err != nil { + t.Fatal(err) + } + + r2 := new(accountserver.Resource) + *r2 = *r + r2.Shard = "host2" + mod := createModifyRequest("dn", r, r2) + if len(mod.ReplaceAttributes) != 1 { + t.Fatalf("bad ModifyRequest after changing shard: %+v", mod) + } + + r2.Email.Aliases = nil + mod = createModifyRequest("dn", r, r2) + if len(mod.DeleteAttributes) != 1 { + t.Fatalf("bad ModifyRequest after deleting aliases: %+v", mod) + } +} diff --git a/server/server.go b/server/server.go index e1da1388..66eb927f 100644 --- a/server/server.go +++ b/server/server.go @@ -5,6 +5,7 @@ import ( "log" "net/http" + "git.autistici.org/ai/go-sso" "git.autistici.org/ai3/go-common/serverutil" "git.autistici.org/ai3/accountserver" @@ -16,11 +17,49 @@ type Backend interface { } type AccountServer struct { - backend Backend + backend Backend + validator sso.Validator } -func New(backend Backend) *AccountServer { - return &AccountServer{backend: backend} +func New(backend Backend, ssoPublicKey []byte, ssoDomain string) (*AccountServer, error) { + v, err := sso.NewValidator(ssoPublicKey, ssoDomain) + if err != nil { + return nil, err + } + return &AccountServer{ + backend: backend, + validator: v, + }, nil +} + +var adminGroup = "admins" + +func isAdmin(tkt *sso.Ticket) bool { + for _, g := range tkt.Groups { + if g == adminGroup { + return true + } + } + return false +} + +func (s *AccountServer) authorize(w http.ResponseWriter, ssoToken, username string) bool { + tkt, err := s.validator.Validate(ssoToken, "", s.ssoService, s.ssoGroups) + if err != nil { + log.Printf("authentication error: %v", err) + http.Error(w, err.Error(), http.StatusUnauthorized) + return false + } + + // Requests are allowed if the SSO ticket corresponds to an admin, or if + // it identifies the same user that we're querying. + if !isAdmin(tkt) && tkt.User != username { + log.Printf("unauthorized access to %s from %s", username, tkt.User) + http.Error(w, err.Error(), http.StatusUnauthorized) + return false + } + + return true } func (s *AccountServer) handleGetUser(w http.ResponseWriter, r *http.Request) { @@ -28,6 +67,9 @@ func (s *AccountServer) handleGetUser(w http.ResponseWriter, r *http.Request) { if !serverutil.DecodeJSONRequest(w, r, &req) { return } + if !s.authorize(w, req.SSO, req.Username) { + return + } user, err := s.backend.GetUser(r.Context(), req.Username) if err != nil { diff --git a/types.go b/types.go index de7f4689..01211a6f 100644 --- a/types.go +++ b/types.go @@ -89,6 +89,46 @@ type Resource struct { Website *Website `json:"website,omitempty"` DAV *WebDAV `json:"dav,omitempty"` Database *Database `json:"database,omitempty"` + + // When the resource is used internally in the accountserver, + // it needs a reference to backend-specific data. This is not + // part of the public interface, and it is not serialized. + opaque interface{} +} + +// SetBackendHandle associates some backend-specific data at runtime +// with this resource. +func (r *Resource) SetBackendHandle(h interface{}) { + r.opaque = h +} + +// GetBackendHandle returns the backend-specific data associated with +// the resource. +func (r *Resource) GetBackendHandle() interface{} { + return r.opaque +} + +// Copy the resource (makes a deep copy). +func (r *Resource) Copy() *Resource { + rr := *r + switch { + case r.Email != nil: + e := *r.Email + rr.Email = &e + case r.Website != nil: + w := *r.Website + rr.Website = &w + case r.List != nil: + l := *r.List + rr.List = &l + case r.DAV != nil: + d := *r.DAV + rr.DAV = &d + case r.Database != nil: + d := *r.Database + rr.Database = &d + } + return &rr } // Email resource attributes. @@ -155,6 +195,7 @@ type Blog struct { // RPC requests. type GetUserRequest struct { + SSO string `json:"sso"` Username string `json:"username"` } -- GitLab