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