From 02d7c9c6e8f273c32608177942759bb246905610 Mon Sep 17 00:00:00 2001
From: ale <ale@incal.net>
Date: Wed, 20 Jun 2018 22:54:49 +0100
Subject: [PATCH] Refactor the LDAP backend

Use a lower level type to abstract LDAP "transactions" (really just
batches of changes) and generate a set of ModifyRequest objects at
commit time. Change the API to let the caller manage the
transaction (TX object) lifetime.
---
 actions.go                  |   8 +-
 actions_test.go             |  31 ++-
 backend/diff.go             |  82 ------
 backend/model.go            | 345 ++++-------------------
 backend/model_test.go       | 181 ++++++++-----
 backend/resources.go        | 527 ++++++++++++++++++++++++++++++++++++
 backend/resources_test.go   |  48 ++++
 backend/testdata/base.ldif  |  17 +-
 backend/testdata/test1.ldif |  94 +++++++
 backend/tx.go               |   5 +
 types.go                    |  19 +-
 11 files changed, 893 insertions(+), 464 deletions(-)
 delete mode 100644 backend/diff.go
 create mode 100644 backend/resources.go
 create mode 100644 backend/resources_test.go
 create mode 100644 backend/testdata/test1.ldif

diff --git a/actions.go b/actions.go
index 7616c0c8..9ac0bfd8 100644
--- a/actions.go
+++ b/actions.go
@@ -52,7 +52,13 @@ type TX interface {
 	SetUserTOTPSecret(context.Context, *User, string) error
 	DeleteUserTOTPSecret(context.Context, *User) error
 
-	HasAnyResource(context.Context, []string) (bool, error)
+	HasAnyResource(context.Context, []FindResourceRequest) (bool, error)
+}
+
+// FindResourceRequest contains parameters for searching a resource by name.
+type FindResourceRequest struct {
+	Type string
+	Name string
 }
 
 // AccountService implements the business logic and high-level
diff --git a/actions_test.go b/actions_test.go
index 4c9443da..a503a80c 100644
--- a/actions_test.go
+++ b/actions_test.go
@@ -15,6 +15,14 @@ type fakeBackend struct {
 	encryptionKeys       map[string][]*UserEncryptionKey
 }
 
+func (b *fakeBackend) NewTransaction() (TX, error) {
+	return b, nil
+}
+
+func (b *fakeBackend) Commit(_ context.Context) error {
+	return nil
+}
+
 func (b *fakeBackend) GetUser(_ context.Context, username string) (*User, error) {
 	return b.users[username], nil
 }
@@ -66,7 +74,7 @@ func (b *fakeBackend) DeleteUserTOTPSecret(_ context.Context, user *User) error
 	return nil
 }
 
-func (b *fakeBackend) HasAnyResource(_ context.Context, rsrcs []string) (bool, error) {
+func (b *fakeBackend) HasAnyResource(_ context.Context, rsrcs []FindResourceRequest) (bool, error) {
 	return false, nil
 }
 
@@ -97,9 +105,8 @@ func createFakeBackend() *fakeBackend {
 				Name: "testuser",
 				Resources: []*Resource{
 					{
-						ID:     "email/testuser@example.com",
+						ID:     NewResourceID("email", "testuser@example.com", "testuser@example.com"),
 						Name:   "testuser@example.com",
-						Type:   ResourceTypeEmail,
 						Status: ResourceStatusActive,
 						Email:  &Email{},
 					},
@@ -122,8 +129,15 @@ func testConfig() *Config {
 	return &c
 }
 
+func testService(admin string) (*AccountService, TX) {
+	be := createFakeBackend()
+	svc := newAccountServiceWithSSO(be, testConfig(), &fakeValidator{admin})
+	tx, _ := be.NewTransaction()
+	return svc, tx
+}
+
 func TestService_GetUser(t *testing.T) {
-	svc := newAccountServiceWithSSO(createFakeBackend(), testConfig(), &fakeValidator{})
+	svc, tx := testService("")
 
 	req := &GetUserRequest{
 		RequestBase: RequestBase{
@@ -131,7 +145,7 @@ func TestService_GetUser(t *testing.T) {
 			SSO:      "testuser",
 		},
 	}
-	resp, err := svc.GetUser(context.TODO(), req)
+	resp, err := svc.GetUser(context.TODO(), tx, req)
 	if err != nil {
 		t.Fatal(err)
 	}
@@ -141,7 +155,7 @@ func TestService_GetUser(t *testing.T) {
 }
 
 func TestService_Auth(t *testing.T) {
-	svc := newAccountServiceWithSSO(createFakeBackend(), testConfig(), &fakeValidator{"adminuser"})
+	svc, tx := testService("adminuser")
 
 	for _, td := range []struct {
 		sso        string
@@ -157,7 +171,7 @@ func TestService_Auth(t *testing.T) {
 				SSO:      td.sso,
 			},
 		}
-		_, err := svc.GetUser(context.TODO(), req)
+		_, err := svc.GetUser(context.TODO(), tx, req)
 		if err != nil {
 			if !IsAuthError(err) {
 				t.Errorf("error for sso_user=%s is not an auth error: %v", td.sso, err)
@@ -172,6 +186,7 @@ func TestService_Auth(t *testing.T) {
 
 func TestService_ChangePassword(t *testing.T) {
 	fb := createFakeBackend()
+	tx, _ := fb.NewTransaction()
 	svc := newAccountServiceWithSSO(fb, testConfig(), &fakeValidator{})
 
 	req := &ChangeUserPasswordRequest{
@@ -184,7 +199,7 @@ func TestService_ChangePassword(t *testing.T) {
 		},
 		Password: "password",
 	}
-	err := svc.ChangeUserPassword(context.TODO(), req)
+	err := svc.ChangeUserPassword(context.TODO(), tx, req)
 	if err != nil {
 		t.Fatal(err)
 	}
diff --git a/backend/diff.go b/backend/diff.go
deleted file mode 100644
index b0c3d00c..00000000
--- a/backend/diff.go
+++ /dev/null
@@ -1,82 +0,0 @@
-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 c76fe919..bb1ea8b4 100644
--- a/backend/model.go
+++ b/backend/model.go
@@ -32,10 +32,11 @@ type backend struct {
 	conn                ldapConn
 	userQuery           *queryConfig
 	userResourceQueries []*queryConfig
-	resourceQueries     map[string]*queryConfig
-	presenceQueries     map[string]*queryConfig
+	resources           *resourceRegistry
 }
 
+// backendTX holds the business logic (that runs within a single
+// transaction).
 type backendTX struct {
 	*ldapTX
 	backend *backend
@@ -61,6 +62,14 @@ func NewLDAPBackend(uri, bindDN, bindPw, base string) (accountserver.Backend, er
 }
 
 func newLDAPBackendWithConn(conn ldapConn, base string) (*backend, error) {
+	rsrc := newResourceRegistry()
+	rsrc.register(accountserver.ResourceTypeEmail, &emailResourceHandler{baseDN: base})
+	rsrc.register(accountserver.ResourceTypeMailingList, &mailingListResourceHandler{baseDN: base})
+	rsrc.register(accountserver.ResourceTypeDAV, &webdavResourceHandler{baseDN: base})
+	rsrc.register(accountserver.ResourceTypeWebsite, &websiteResourceHandler{baseDN: base})
+	rsrc.register(accountserver.ResourceTypeDomain, &domainResourceHandler{baseDN: base})
+	rsrc.register(accountserver.ResourceTypeDatabase, &databaseResourceHandler{baseDN: base})
+
 	return &backend{
 		conn: conn,
 		userQuery: mustCompileQueryConfig(&queryConfig{
@@ -80,60 +89,7 @@ func newLDAPBackendWithConn(conn ldapConn, base string) (*backend, error) {
 				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",
-			}),
-		},
-		presenceQueries: map[string]*queryConfig{
-			accountserver.ResourceTypeEmail: mustCompileQueryConfig(&queryConfig{
-				Base:   "ou=People," + base,
-				Filter: "(&(objectClass=virtualMailUser)(mail=${resource}))",
-				Scope:  "sub",
-			}),
-			accountserver.ResourceTypeWebsite: mustCompileQueryConfig(&queryConfig{
-				Base:   "ou=People," + base,
-				Filter: "(|(&(objectClass=subSite)(alias=${resource}))(&(objectClass=virtualHost)(cn=${resource})))",
-				Scope:  "sub",
-			}),
-			accountserver.ResourceTypeDAV: mustCompileQueryConfig(&queryConfig{
-				Base:   "ou=People," + base,
-				Filter: "(&(objectClass=ftpAccount)(ftpname=${resource}))",
-				Scope:  "sub",
-			}),
-			accountserver.ResourceTypeDatabase: mustCompileQueryConfig(&queryConfig{
-				Base:   "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",
-			}),
-		},
+		resources: rsrc,
 	}, nil
 }
 
@@ -207,18 +163,6 @@ func b2s(b bool) string {
 	return "no"
 }
 
-func newResourceFromLDAP(entry *ldap.Entry, resourceType, nameAttr string) *accountserver.Resource {
-	name := entry.GetAttributeValue(nameAttr)
-	return &accountserver.Resource{
-		ID:            accountserver.NewResourceID(resourceType, name),
-		Name:          name,
-		Type:          resourceType,
-		Status:        entry.GetAttributeValue("status"),
-		Shard:         entry.GetAttributeValue("host"),
-		OriginalShard: entry.GetAttributeValue("originalHost"),
-	}
-}
-
 // 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 {
@@ -228,153 +172,6 @@ func s2l(s string) []string {
 	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{
-		Aliases: entry.GetAttributeValues("mailAlternateAddr"),
-		Maildir: entry.GetAttributeValue("mailMessageStore"),
-	}
-	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{
-		Public: s2b(entry.GetAttributeValue("public")),
-		Admins: entry.GetAttributeValues("listOwner"),
-	}
-	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{
-		Homedir: entry.GetAttributeValue("homeDirectory"),
-	}
-	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") {
-		r = newResourceFromLDAP(entry, accountserver.ResourceTypeWebsite, "alias")
-		r.Website = &accountserver.Website{
-			URL:         fmt.Sprintf("https://www.%s/%s/", entry.GetAttributeValue("parentSite"), r.Name),
-			DisplayName: fmt.Sprintf("%s/%s", entry.GetAttributeValue("parentSite"), r.Name),
-		}
-	} else {
-		r = newResourceFromLDAP(entry, accountserver.ResourceTypeWebsite, "cn")
-		r.Website = &accountserver.Website{
-			URL:         fmt.Sprintf("https://%s/", r.Name),
-			DisplayName: r.Name,
-		}
-	}
-	r.Website.Options = entry.GetAttributeValues("option")
-	r.Website.DocumentRoot = entry.GetAttributeValue("documentRoot")
-	r.Website.AcceptMail = s2b(entry.GetAttributeValue("acceptMail"))
-	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{
-		DBUser:            entry.GetAttributeValue("dbuser"),
-		CleartextPassword: entry.GetAttributeValue("clearPassword"),
-	}
-
-	// Databases are nested below websites, so we set the ParentID by
-	// looking at the LDAP DN.
-	if dn, err := ldap.ParseDN(entry.DN); err == nil {
-		parentRDN := dn.RDNs[1]
-		r.ParentID = accountserver.NewResourceID(accountserver.ResourceTypeWebsite, dn.RDNs[2].Attributes[0].Value, parentRDN.Attributes[0].Value)
-	}
-
-	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"),
@@ -429,8 +226,9 @@ func (tx *backendTX) GetUser(ctx context.Context, username string) (*accountserv
 				user.PasswordRecoveryHint = entry.GetAttributeValue("recoverQuestion")
 				user.AppSpecificPasswords = getASPInfo(decodeAppSpecificPasswords(entry.GetAttributeValues("appSpecificPassword")))
 			}
+
 			// Parse the resource and add it to the User.
-			if r, err := parseLdapResource(entry); err == nil {
+			if r, err := tx.backend.resources.FromLDAP(entry); err == nil {
 				user.Resources = append(user.Resources, r)
 			}
 		}
@@ -467,18 +265,6 @@ func (tx *backendTX) readAttributeValues(ctx context.Context, dn, attribute stri
 	return result.Entries[0].GetAttributeValues(attribute)
 }
 
-func (tx *backendTX) readAttributeValue(ctx context.Context, dn, attribute string) string {
-	req := singleAttributeQuery(dn, attribute)
-	result, err := tx.search(ctx, req)
-	if err != nil {
-		return ""
-	}
-	if len(result.Entries) < 1 {
-		return ""
-	}
-	return result.Entries[0].GetAttributeValue(attribute)
-}
-
 func (tx *backendTX) SetUserPassword(ctx context.Context, user *accountserver.User, encryptedPassword string) error {
 	tx.setAttr(getUserDN(user), "userPassword", encryptedPassword)
 	return nil
@@ -515,7 +301,7 @@ func (tx *backendTX) SetApplicationSpecificPassword(ctx context.Context, user *a
 	if emailRsrc == nil {
 		return errors.New("no email resource")
 	}
-	emailDN := getResourceDN(user.Name, emailRsrc)
+	emailDN, _ := tx.backend.resources.GetDN(emailRsrc.ID)
 
 	// Obtain the full list of ASPs from the backend and replace/append the new one.
 	asps := decodeAppSpecificPasswords(tx.readAttributeValues(ctx, emailDN, "appSpecificPassword"))
@@ -531,7 +317,7 @@ func (tx *backendTX) DeleteApplicationSpecificPassword(ctx context.Context, user
 	if emailRsrc == nil {
 		return errors.New("no email resource")
 	}
-	emailDN := getResourceDN(user.Name, emailRsrc)
+	emailDN, _ := tx.backend.resources.GetDN(emailRsrc.ID)
 
 	asps := decodeAppSpecificPasswords(tx.readAttributeValues(ctx, emailDN, "appSpecificPassword"))
 	asps = excludeASPFromList(asps, id)
@@ -552,20 +338,15 @@ func (tx *backendTX) DeleteUserTOTPSecret(ctx context.Context, user *accountserv
 }
 
 func (tx *backendTX) SetResourcePassword(ctx context.Context, username string, r *accountserver.Resource, encryptedPassword string) error {
-	tx.setAttr(getResourceDN(username, r), "userPassword", encryptedPassword)
+	dn, _ := tx.backend.resources.GetDN(r.ID)
+	tx.setAttr(dn, "userPassword", encryptedPassword)
 	return nil
 }
 
-func parseResourceID(resourceID string) (string, string) {
-	parts := strings.SplitN(resourceID, "/", 2)
-	return parts[0], parts[1]
-}
-
-func (tx *backendTX) hasResource(ctx context.Context, resourceID string) (bool, error) {
-	resourceType, resourceName := parseResourceID(resourceID)
-	query, ok := tx.backend.presenceQueries[resourceType]
-	if !ok {
-		return false, errors.New("unsupported resource type")
+func (tx *backendTX) hasResource(ctx context.Context, resourceType, resourceName string) (bool, error) {
+	query, err := tx.backend.resources.SearchQuery(resourceType)
+	if err != nil {
+		return false, err
 	}
 
 	// Make a quick LDAP search that only fetches the DN attribute.
@@ -586,9 +367,9 @@ func (tx *backendTX) hasResource(ctx context.Context, resourceID string) (bool,
 }
 
 // HasAnyResource returns true if any of the specified resources exists.
-func (tx *backendTX) HasAnyResource(ctx context.Context, resourceIDs []string) (bool, error) {
-	for _, resourceID := range resourceIDs {
-		has, err := tx.hasResource(ctx, resourceID)
+func (tx *backendTX) HasAnyResource(ctx context.Context, resourceIDs []accountserver.FindResourceRequest) (bool, error) {
+	for _, req := range resourceIDs {
+		has, err := tx.hasResource(ctx, req.Type, req.Name)
 		if err != nil || has {
 			return has, err
 		}
@@ -598,17 +379,32 @@ func (tx *backendTX) HasAnyResource(ctx context.Context, resourceIDs []string) (
 
 // GetResource returns a ResourceWrapper, as part of a read-modify-update transaction.
 func (tx *backendTX) GetResource(ctx context.Context, username, resourceID string) (*accountserver.Resource, error) {
-	resourceType, resourceName := parseResourceID(resourceID)
-	query, ok := tx.backend.resourceQueries[resourceType]
-	if !ok {
-		return nil, errors.New("unsupported resource type")
+	rsrcID, err := accountserver.ParseResourceID(resourceID)
+	if err != nil {
+		return nil, err
 	}
 
-	result, err := tx.search(ctx, query.searchRequest(map[string]string{
-		"user":     username,
-		"resource": resourceName,
-		"type":     resourceType,
-	}, nil))
+	// From the resource ID we can obtain the DN, and fetch it
+	// straight from LDAP without even doing a real search.
+	dn, err := tx.backend.resources.GetDN(rsrcID)
+	if err != nil {
+		return nil, err
+	}
+
+	// This is just a 'point' search.
+	req := ldap.NewSearchRequest(
+		dn,
+		ldap.ScopeBaseObject,
+		ldap.NeverDerefAliases,
+		0,
+		0,
+		false,
+		"(objectClass=*)",
+		nil,
+		nil,
+	)
+
+	result, err := tx.search(ctx, req)
 	if err != nil {
 		if ldap.IsErrorWithCode(err, ldap.LDAPResultNoSuchObject) {
 			return nil, nil
@@ -616,51 +412,26 @@ func (tx *backendTX) GetResource(ctx context.Context, username, resourceID strin
 		return nil, err
 	}
 
-	return parseLdapResource(result.Entries[0])
+	// We know the resource type so we don't have to guess.
+	return tx.backend.resources.FromLDAPWithType(rsrcID.Type(), result.Entries[0])
 }
 
 // UpdateResource updates a LDAP-backed resource that was obtained by a previous GetResource call.
 func (tx *backendTX) UpdateResource(ctx context.Context, username string, r *accountserver.Resource) error {
-	dn := getResourceDN(username, r)
+	dn, err := tx.backend.resources.GetDN(r.ID)
+	if err != nil {
+		return err
+	}
 
 	// We can simply dump all attribute/value pairs and let the
 	// code in ldapTX do the work of finding the differences.
-	for _, attr := range resourceToLDAP(r) {
+	for _, attr := range tx.backend.resources.ToLDAP(r) {
 		tx.setAttr(dn, attr.Type, attr.Vals...)
 	}
 
 	return nil
 }
 
-var dnByResourceType = map[string]string{
-	accountserver.ResourceTypeEmail: "mail=${resource},uid=${user},ou=People",
-}
-
-func getResourceDN(username string, r *accountserver.Resource) string {
-	return replaceVars(dnByResourceType[r.Type], map[string]string{
-		"user":     username,
-		"resource": r.Name,
-	})
-}
-
-func parseLdapResource(entry *ldap.Entry) (r *accountserver.Resource, err error) {
-	switch {
-	case isObjectClass(entry, "virtualMailUser"):
-		r, err = newEmailResource(entry)
-	case isObjectClass(entry, "ftpAccount"):
-		r, err = newWebDAVResource(entry)
-	case isObjectClass(entry, "mailingList"):
-		r, err = newMailingListResource(entry)
-	case isObjectClass(entry, "dbMysql"):
-		r, err = newDatabaseResource(entry)
-	case isObjectClass(entry, "subSite") || isObjectClass(entry, "virtualHost"):
-		r, err = newWebsiteResource(entry)
-	default:
-		return nil, errors.New("unknown LDAP resource")
-	}
-	return
-}
-
 func isObjectClass(entry *ldap.Entry, class string) bool {
 	classes := entry.GetAttributeValues("objectClass")
 	for _, c := range classes {
@@ -692,7 +463,7 @@ func groupWebResourcesByHomedir(resources []*accountserver.Resource) {
 	// group for databases too, via their ParentID.
 	webs := make(map[string]*accountserver.Resource)
 	for _, r := range resources {
-		switch r.Type {
+		switch r.ID.Type() {
 		case accountserver.ResourceTypeWebsite:
 			r.Group = getHostingDir(r.Website.DocumentRoot)
 			webs[r.ID.String()] = r
@@ -702,7 +473,7 @@ func groupWebResourcesByHomedir(resources []*accountserver.Resource) {
 	}
 	// Fix databases in a second pass.
 	for _, r := range resources {
-		if r.Type == accountserver.ResourceTypeDatabase && !r.ParentID.Empty() {
+		if r.ID.Type() == accountserver.ResourceTypeDatabase && !r.ParentID.Empty() {
 			r.Group = webs[r.ParentID.String()].Group
 		}
 	}
diff --git a/backend/model_test.go b/backend/model_test.go
index 2a1354fa..cedc36e7 100644
--- a/backend/model_test.go
+++ b/backend/model_test.go
@@ -1,100 +1,149 @@
 package backend
 
 import (
-	"reflect"
+	"context"
+	"fmt"
 	"testing"
 
 	"git.autistici.org/ai3/accountserver"
-	ldap "gopkg.in/ldap.v2"
+	"github.com/go-test/deep"
 )
 
-// Compare resources, ignoring the Opaque member.
-func resourcesEqual(a, b *accountserver.Resource) bool {
-	aa := *a
-	bb := *b
-	return reflect.DeepEqual(aa, bb)
-}
+const (
+	testLDAPPort = 42871
+	testLDAPAddr = "ldap://127.0.0.1:42871"
+	testUser1    = "uno@investici.org"
+)
 
-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"},
-		},
-	)
+func TestModel_GetUser(t *testing.T) {
+	stop := startTestLDAPServer(t, &testLDAPServerConfig{
+		Port:  testLDAPPort,
+		Base:  "dc=example,dc=com",
+		LDIFs: []string{"testdata/base.ldif", "testdata/test1.ldif"},
+	})
+	defer stop()
+
+	b, err := NewLDAPBackend(testLDAPAddr, "cn=manager,dc=example,dc=com", "password", "dc=example,dc=com")
+	if err != nil {
+		t.Fatal("NewLDAPBackend", err)
+	}
 
-	r, err := parseLdapResource(entry)
+	tx, _ := b.NewTransaction()
+	user, err := tx.GetUser(context.Background(), testUser1)
 	if err != nil {
-		t.Fatal(err)
-	}
-
-	expected := &accountserver.Resource{
-		ID:            accountserver.NewResourceID("email", "test@investici.org", "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",
+		t.Fatal("GetUser", err)
+	}
+	if user == nil {
+		t.Fatalf("could not find test user %s", testUser1)
+	}
+
+	//t.Logf("%+v", user)
+
+	if user.Name != testUser1 {
+		t.Fatalf("bad username: expected %s, got %s", testUser1, user.Name)
+	}
+	if len(user.Resources) != 4 {
+		t.Fatalf("expected 4 resources, got %d", len(user.Resources))
+	}
+
+	// Test a specific resource (the database).
+	db := user.GetSingleResourceByType(accountserver.ResourceTypeDatabase)
+	expectedDB := &accountserver.Resource{
+		ID: accountserver.NewResourceID(
+			accountserver.ResourceTypeDatabase,
+			testUser1,
+			"alias=uno",
+			"unodb",
+		),
+		ParentID: accountserver.NewResourceID(
+			accountserver.ResourceTypeWebsite,
+			testUser1,
+			"uno",
+		),
+		Name:          "unodb",
+		Shard:         "host2",
+		OriginalShard: "host2",
+		Group:         "uno",
+		Status:        accountserver.ResourceStatusActive,
+		Database: &accountserver.Database{
+			CleartextPassword: "password",
+			DBUser:            "unodb",
 		},
 	}
-	if !resourcesEqual(r, expected) {
-		t.Fatalf("bad result: got %+v, expected %+v", r, expected)
+	if err := deep.Equal(db, expectedDB); err != nil {
+		t.Fatalf("returned database resource differs: %v", err)
 	}
 }
 
-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"},
-		},
-	)
+func TestModel_SetResourceStatus(t *testing.T) {
+	stop := startTestLDAPServer(t, &testLDAPServerConfig{
+		Port:  testLDAPPort,
+		Base:  "dc=example,dc=com",
+		LDIFs: []string{"testdata/base.ldif", "testdata/test1.ldif"},
+	})
+	defer stop()
 
-	r, err := parseLdapResource(entry)
+	b, err := NewLDAPBackend(testLDAPAddr, "cn=manager,dc=example,dc=com", "password", "dc=example,dc=com")
 	if err != nil {
-		t.Fatal(err)
+		t.Fatal("NewLDAPBackend", 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)
+	tx, _ := b.NewTransaction()
+	rsrcID := fmt.Sprintf("email/%s/%s", testUser1, testUser1)
+	r, err := tx.GetResource(context.Background(), testUser1, rsrcID)
+	if err != nil {
+		t.Fatal("GetResource", err)
 	}
+	if r == nil {
+		t.Fatalf("could not find test resource %s", rsrcID)
+	}
+
+	//t.Logf("%+v", r)
 
-	r2.Email.Aliases = nil
-	mod = createModifyRequest("dn", r, r2)
-	if len(mod.DeleteAttributes) != 1 {
-		t.Fatalf("bad ModifyRequest after deleting aliases: %+v", mod)
+	r.Status = accountserver.ResourceStatusInactive
+	if err := tx.UpdateResource(context.Background(), "uno@investici.org", r); err != nil {
+		t.Fatal("UpdateResource", err)
+	}
+	if err := tx.Commit(context.Background()); err != nil {
+		t.Fatalf("commit error: %v", err)
 	}
 }
 
-func TestConn(t *testing.T) {
+func TestModel_HasAnyResource(t *testing.T) {
 	stop := startTestLDAPServer(t, &testLDAPServerConfig{
-		Port:  42781,
+		Port:  testLDAPPort,
 		Base:  "dc=example,dc=com",
-		LDIFs: []string{"testdata/base.ldif"},
+		LDIFs: []string{"testdata/base.ldif", "testdata/test1.ldif"},
 	})
 	defer stop()
 
-	_, err := NewLDAPBackend("ldap://127.0.0.1:42781", "cn=manager,dc=example,dc=com", "password", "dc=example,dc=com")
+	b, err := NewLDAPBackend(testLDAPAddr, "cn=manager,dc=example,dc=com", "password", "dc=example,dc=com")
 	if err != nil {
 		t.Fatal("NewLDAPBackend", err)
 	}
+
+	tx, _ := b.NewTransaction()
+
+	// Request that should succeed.
+	ok, err := tx.HasAnyResource(context.Background(), []accountserver.FindResourceRequest{
+		{Type: accountserver.ResourceTypeEmail, Name: "foo"},
+		{Type: accountserver.ResourceTypeEmail, Name: testUser1},
+	})
+	if err != nil {
+		t.Fatal("HasAnyResource", err)
+	}
+	if !ok {
+		t.Fatal("could not find test resource")
+	}
+
+	// Request that should fail (bad resource type).
+	ok, err = tx.HasAnyResource(context.Background(), []accountserver.FindResourceRequest{
+		{Type: accountserver.ResourceTypeDatabase, Name: testUser1},
+	})
+	if err != nil {
+		t.Fatal("HasAnyResource", err)
+	}
+	if ok {
+		t.Fatal("oops, found non existing resource")
+	}
 }
diff --git a/backend/resources.go b/backend/resources.go
new file mode 100644
index 00000000..ea48de46
--- /dev/null
+++ b/backend/resources.go
@@ -0,0 +1,527 @@
+package backend
+
+import (
+	"errors"
+	"fmt"
+	"strings"
+
+	"gopkg.in/ldap.v2"
+
+	"git.autistici.org/ai3/accountserver"
+)
+
+// Generic resource handler interface. One for each resource type,
+// mapping to exactly one LDAP object type.
+type resourceHandler interface {
+	GetDN(accountserver.ResourceID) (string, error)
+	ToLDAP(*accountserver.Resource) []ldap.PartialAttribute
+	FromLDAP(*ldap.Entry) (*accountserver.Resource, error)
+	SearchQuery() *queryConfig
+}
+
+// Registry for demultiplexing resource handling. Has a similar
+// interface to a resourceHandler, with a few exceptions.
+type resourceRegistry struct {
+	handlers map[string]resourceHandler
+}
+
+func newResourceRegistry() *resourceRegistry {
+	return &resourceRegistry{
+		handlers: make(map[string]resourceHandler),
+	}
+}
+
+func (reg *resourceRegistry) register(rtype string, h resourceHandler) {
+	if reg.handlers == nil {
+		reg.handlers = make(map[string]resourceHandler)
+	}
+	reg.handlers[rtype] = h
+}
+
+func (reg *resourceRegistry) dispatch(rsrcType string, f func(resourceHandler) error) error {
+	h, ok := reg.handlers[rsrcType]
+	if !ok {
+		return errors.New("unknown resource type")
+	}
+	return f(h)
+}
+
+func (reg *resourceRegistry) GetDN(id accountserver.ResourceID) (s string, err error) {
+	err = reg.dispatch(id.Type(), func(h resourceHandler) (herr error) {
+		s, herr = h.GetDN(id)
+		return
+	})
+	return
+}
+
+func (reg *resourceRegistry) ToLDAP(rsrc *accountserver.Resource) (attrs []ldap.PartialAttribute) {
+	if err := reg.dispatch(rsrc.ID.Type(), func(h resourceHandler) error {
+		attrs = h.ToLDAP(rsrc)
+		return nil
+	}); err != nil {
+		return nil
+	}
+
+	attrs = append(attrs, []ldap.PartialAttribute{
+		{Type: "status", Vals: s2l(rsrc.Status)},
+		{Type: "host", Vals: s2l(rsrc.Shard)},
+		{Type: "originalHost", Vals: s2l(rsrc.OriginalShard)},
+	}...)
+	return
+}
+
+func setCommonResourceAttrs(entry *ldap.Entry, rsrc *accountserver.Resource) {
+	rsrc.Status = entry.GetAttributeValue("status")
+	rsrc.Shard = entry.GetAttributeValue("host")
+	rsrc.OriginalShard = entry.GetAttributeValue("originalHost")
+}
+
+func (reg *resourceRegistry) FromLDAP(entry *ldap.Entry) (rsrc *accountserver.Resource, err error) {
+	// Since we don't know what resource type to expect, we try
+	// all known handlers until one returns a valid Resource.
+	for _, h := range reg.handlers {
+		rsrc, err = h.FromLDAP(entry)
+		if err == nil {
+			setCommonResourceAttrs(entry, rsrc)
+			return
+		}
+	}
+	return nil, errors.New("unknown resource")
+}
+
+func (reg *resourceRegistry) FromLDAPWithType(rsrcType string, entry *ldap.Entry) (rsrc *accountserver.Resource, err error) {
+	err = reg.dispatch(rsrcType, func(h resourceHandler) (rerr error) {
+		rsrc, rerr = h.FromLDAP(entry)
+		if rerr != nil {
+			return
+		}
+		setCommonResourceAttrs(entry, rsrc)
+		return
+	})
+	return
+}
+
+func (reg *resourceRegistry) SearchQuery(rsrcType string) (c *queryConfig, err error) {
+	err = reg.dispatch(rsrcType, func(h resourceHandler) error {
+		c = h.SearchQuery()
+		return nil
+	})
+	return
+}
+
+// Find the parent RDN, which is expected to have the specified
+// attribute, and return its value.
+func getParentRDN(dn, parentAttr string) (string, error) {
+	parsed, err := ldap.ParseDN(dn)
+	if err != nil {
+		return "", err
+	}
+	if len(parsed.RDNs) < 2 {
+		return "", errors.New("not enough DN components to find parent")
+	}
+	if parsed.RDNs[1].Attributes[0].Type != parentAttr {
+		return "", errors.New("parent RDN has unexpected type")
+	}
+	return parsed.RDNs[1].Attributes[0].Value, nil
+}
+
+// Email resource.
+type emailResourceHandler struct {
+	baseDN string
+}
+
+func (h *emailResourceHandler) GetDN(id accountserver.ResourceID) (string, error) {
+	if id.User() == "" {
+		return "", errors.New("unqualified resource id")
+	}
+	dn := replaceVars("mail=${resource},uid=${user},ou=People", map[string]string{
+		"user":     id.User(),
+		"resource": id.Name(),
+	})
+	return joinDN(dn, h.baseDN), nil
+}
+
+var errWrongObjectClass = errors.New("objectClass does not match")
+
+func (h *emailResourceHandler) FromLDAP(entry *ldap.Entry) (*accountserver.Resource, error) {
+	if !isObjectClass(entry, "virtualMailUser") {
+		return nil, errWrongObjectClass
+	}
+
+	email := entry.GetAttributeValue("mail")
+	username, err := getParentRDN(entry.DN, "uid")
+	if err != nil {
+		return nil, err
+	}
+
+	return &accountserver.Resource{
+		ID: accountserver.NewResourceID(
+			accountserver.ResourceTypeEmail,
+			username,
+			email,
+		),
+		Name: email,
+		Email: &accountserver.Email{
+			Aliases: entry.GetAttributeValues("mailAlternateAddr"),
+			Maildir: entry.GetAttributeValue("mailMessageStore"),
+		},
+	}, nil
+}
+
+func (h *emailResourceHandler) ToLDAP(rsrc *accountserver.Resource) []ldap.PartialAttribute {
+	return []ldap.PartialAttribute{
+		{Type: "objectClass", Vals: []string{"top", "virtualMailUser"}},
+		{Type: "mail", Vals: s2l(rsrc.ID.Name())},
+		{Type: "mailAlternateAddr", Vals: rsrc.Email.Aliases},
+		{Type: "mailMessageStore", Vals: s2l(rsrc.Email.Maildir)},
+	}
+}
+
+func (h *emailResourceHandler) SearchQuery() *queryConfig {
+	return mustCompileQueryConfig(&queryConfig{
+		Base:   joinDN("ou=People", h.baseDN),
+		Filter: "(&(objectClass=virtualMailUser)(mail=${resource}))",
+		Scope:  "sub",
+	})
+}
+
+// Mailing list resource.
+type mailingListResourceHandler struct {
+	baseDN string
+}
+
+func (h *mailingListResourceHandler) GetDN(id accountserver.ResourceID) (string, error) {
+	dn := replaceVars("listName=${resource},ou=Lists", map[string]string{
+		"resource": id.Name(),
+	})
+	return joinDN(dn, h.baseDN), nil
+}
+
+func (h *mailingListResourceHandler) FromLDAP(entry *ldap.Entry) (*accountserver.Resource, error) {
+	if !isObjectClass(entry, "mailingList") {
+		return nil, errWrongObjectClass
+	}
+
+	listName := entry.GetAttributeValue("listName")
+	return &accountserver.Resource{
+		ID:   accountserver.NewResourceID(accountserver.ResourceTypeMailingList, listName),
+		Name: listName,
+		List: &accountserver.MailingList{
+			Public: s2b(entry.GetAttributeValue("public")),
+			Admins: entry.GetAttributeValues("listOwner"),
+		},
+	}, nil
+}
+
+func (h *mailingListResourceHandler) ToLDAP(rsrc *accountserver.Resource) []ldap.PartialAttribute {
+	return []ldap.PartialAttribute{
+		{Type: "objectClass", Vals: []string{"top", "mailingList"}},
+		{Type: "listName", Vals: s2l(rsrc.ID.Name())},
+		{Type: "public", Vals: s2l(b2s(rsrc.List.Public))},
+		{Type: "listOwner", Vals: rsrc.List.Admins},
+	}
+}
+
+func (h *mailingListResourceHandler) SearchQuery() *queryConfig {
+	return mustCompileQueryConfig(&queryConfig{
+		Base:   joinDN("ou=Lists", h.baseDN),
+		Filter: "(&(objectClass=mailingList)(listName=${resource}))",
+		Scope:  "one",
+	})
+}
+
+// Website (subsite) resource.
+type websiteResourceHandler struct {
+	baseDN string
+}
+
+func (h *websiteResourceHandler) GetDN(id accountserver.ResourceID) (string, error) {
+	if id.User() == "" {
+		return "", errors.New("unqualified resource id")
+	}
+
+	dn := replaceVars("alias=${resource},uid=${user},ou=People", map[string]string{
+		"user":     id.User(),
+		"resource": id.Name(),
+	})
+	return joinDN(dn, h.baseDN), nil
+}
+
+func (h *websiteResourceHandler) FromLDAP(entry *ldap.Entry) (*accountserver.Resource, error) {
+	if !isObjectClass(entry, "subSite") {
+		return nil, errWrongObjectClass
+	}
+
+	alias := entry.GetAttributeValue("alias")
+	parentSite := entry.GetAttributeValue("parentSite")
+	name := fmt.Sprintf("%s/%s", parentSite, alias)
+	url := fmt.Sprintf("https://www.%s/%s/", parentSite, alias)
+
+	username, err := getParentRDN(entry.DN, "uid")
+	if err != nil {
+		return nil, err
+	}
+	return &accountserver.Resource{
+		ID: accountserver.NewResourceID(
+			accountserver.ResourceTypeWebsite,
+			username,
+			alias,
+		),
+		Name: name,
+		Website: &accountserver.Website{
+			URL:          url,
+			ParentDomain: parentSite,
+			Options:      entry.GetAttributeValues("option"),
+			DocumentRoot: entry.GetAttributeValue("documentRoot"),
+			AcceptMail:   s2b(entry.GetAttributeValue("acceptMail")),
+		},
+	}, nil
+}
+
+func (h *websiteResourceHandler) ToLDAP(rsrc *accountserver.Resource) []ldap.PartialAttribute {
+	return []ldap.PartialAttribute{
+		{Type: "objectClass", Vals: []string{"top", "subSite"}},
+		{Type: "alias", Vals: s2l(rsrc.ID.Name())},
+		{Type: "parentSite", Vals: s2l(rsrc.Website.ParentDomain)},
+		{Type: "option", Vals: rsrc.Website.Options},
+		{Type: "documentRoot", Vals: s2l(rsrc.Website.DocumentRoot)},
+		{Type: "acceptMail", Vals: s2l(b2s(rsrc.Website.AcceptMail))},
+	}
+}
+
+func (h *websiteResourceHandler) SearchQuery() *queryConfig {
+	return mustCompileQueryConfig(&queryConfig{
+		Base:   joinDN("ou=People", h.baseDN),
+		Filter: "(&(objectClass=subSite)(alias=${resource}))",
+		Scope:  "sub",
+	})
+}
+
+// Domain (virtual host) resource.
+type domainResourceHandler struct {
+	baseDN string
+}
+
+func (h *domainResourceHandler) GetDN(id accountserver.ResourceID) (string, error) {
+	if id.User() == "" {
+		return "", errors.New("unqualified resource id")
+	}
+
+	dn := replaceVars("cn=${resource},uid=${user},ou=People", map[string]string{
+		"user":     id.User(),
+		"resource": id.Name(),
+	})
+	return joinDN(dn, h.baseDN), nil
+}
+
+func (h *domainResourceHandler) FromLDAP(entry *ldap.Entry) (*accountserver.Resource, error) {
+	if !isObjectClass(entry, "virtualHost") {
+		return nil, errWrongObjectClass
+	}
+
+	cn := entry.GetAttributeValue("cn")
+	username, err := getParentRDN(entry.DN, "uid")
+	if err != nil {
+		return nil, err
+	}
+	return &accountserver.Resource{
+		ID: accountserver.NewResourceID(
+			accountserver.ResourceTypeDomain,
+			username,
+			cn,
+		),
+		Name: cn,
+		Website: &accountserver.Website{
+			URL:          fmt.Sprintf("https://%s/", cn),
+			Options:      entry.GetAttributeValues("option"),
+			DocumentRoot: entry.GetAttributeValue("documentRoot"),
+			AcceptMail:   s2b(entry.GetAttributeValue("acceptMail")),
+		},
+	}, nil
+}
+
+func (h *domainResourceHandler) ToLDAP(rsrc *accountserver.Resource) []ldap.PartialAttribute {
+	return []ldap.PartialAttribute{
+		{Type: "objectClass", Vals: []string{"top", "virtualHost"}},
+		{Type: "cn", Vals: s2l(rsrc.ID.Name())},
+		{Type: "option", Vals: rsrc.Website.Options},
+		{Type: "documentRoot", Vals: s2l(rsrc.Website.DocumentRoot)},
+		{Type: "acceptMail", Vals: s2l(b2s(rsrc.Website.AcceptMail))},
+	}
+}
+
+func (h *domainResourceHandler) SearchQuery() *queryConfig {
+	return mustCompileQueryConfig(&queryConfig{
+		Base:   joinDN("ou=People", h.baseDN),
+		Filter: "(&(objectClass=virtualHost)(cn=${resource}))",
+		Scope:  "sub",
+	})
+}
+
+// WebDAV (a.k.a. "ftp account") resource.
+type webdavResourceHandler struct {
+	baseDN string
+}
+
+func (h *webdavResourceHandler) GetDN(id accountserver.ResourceID) (string, error) {
+	if id.User() == "" {
+		return "", errors.New("unqualified resource id")
+	}
+
+	dn := replaceVars("cn=${resource},uid=${user},ou=People", map[string]string{
+		"user":     id.User(),
+		"resource": id.Name(),
+	})
+	return joinDN(dn, h.baseDN), nil
+}
+
+func (h *webdavResourceHandler) FromLDAP(entry *ldap.Entry) (*accountserver.Resource, error) {
+	if !isObjectClass(entry, "ftpAccount") {
+		return nil, errWrongObjectClass
+	}
+
+	name := entry.GetAttributeValue("ftpname")
+	username, err := getParentRDN(entry.DN, "uid")
+	if err != nil {
+		return nil, err
+	}
+	return &accountserver.Resource{
+		ID: accountserver.NewResourceID(
+			accountserver.ResourceTypeDAV,
+			username,
+			name,
+		),
+		Name: name,
+		DAV: &accountserver.WebDAV{
+			Homedir: entry.GetAttributeValue("homeDirectory"),
+		},
+	}, nil
+}
+
+func (h *webdavResourceHandler) ToLDAP(rsrc *accountserver.Resource) []ldap.PartialAttribute {
+	return []ldap.PartialAttribute{
+		{Type: "objectClass", Vals: []string{"top", "person", "posixAccount", "shadowAccount", "organizationalPerson", "inetOrgPerson", "ftpAccount"}},
+		{Type: "ftpname", Vals: s2l(rsrc.ID.Name())},
+		{Type: "homeDirectory", Vals: s2l(rsrc.DAV.Homedir)},
+	}
+}
+
+func (h *webdavResourceHandler) SearchQuery() *queryConfig {
+	return mustCompileQueryConfig(&queryConfig{
+		Base:   joinDN("ou=People", h.baseDN),
+		Filter: "(&(objectClass=ftpAccount)(ftpname=${resource}))",
+		Scope:  "sub",
+	})
+}
+
+// Databases are special: in LDAP, they encode their relation with a
+// website using the database hierarchy. This means that, in order to
+// satisfy the requirement to generate a DN directly from every
+// resource ID, we need to encode the parent website in the resource
+// ID itself. We do this using not just the website name (which would
+// be ambiguous: Website or Domain?), but also the type, using an
+// attr=name syntax.
+type databaseResourceHandler struct {
+	baseDN string
+}
+
+func makeDatabaseResourceID(dn string) (rsrcID accountserver.ResourceID, parentID accountserver.ResourceID, err error) {
+	parsed, perr := ldap.ParseDN(dn)
+	if perr != nil {
+		err = perr
+		return
+	}
+	if len(parsed.RDNs) < 3 {
+		err = errors.New("not enough DN components for database")
+		return
+	}
+
+	// The database name is the first RDN.
+	dbname := parsed.RDNs[0].Attributes[0].Value
+	// The encoded parent website name is type=value of the 2nd component.
+	parentName := parsed.RDNs[1].Attributes[0].Value
+	encParent := fmt.Sprintf("%s=%s", parsed.RDNs[1].Attributes[0].Type, parentName)
+	// The username is the 3rd component.
+	username := parsed.RDNs[2].Attributes[0].Value
+
+	rsrcID = accountserver.NewResourceID(
+		accountserver.ResourceTypeDatabase,
+		username,
+		encParent,
+		dbname,
+	)
+	var parentType = accountserver.ResourceTypeWebsite
+	if parsed.RDNs[1].Attributes[0].Type == "cn" {
+		parentType = accountserver.ResourceTypeDomain
+	}
+	parentID = accountserver.NewResourceID(
+		parentType,
+		username,
+		parentName,
+	)
+	return
+}
+
+func (h *databaseResourceHandler) GetDN(id accountserver.ResourceID) (string, error) {
+	if id.User() == "" || len(id.Parts) < 4 {
+		return "", errors.New("unqualified resource id")
+	}
+
+	// Decode the parent website as encoded in
+	// makeDatabaseResourceID. The parent website is the third
+	// path component in the ID.
+	parentParts := strings.SplitN(id.Parts[2], "=", 2)
+	if len(parentParts) != 2 {
+		return "", errors.New("malformed database resource id")
+	}
+
+	dn := replaceVars("dbname=${resource},${parentType}=${parent},uid=${user},ou=People", map[string]string{
+		"user":       id.User(),
+		"parentType": parentParts[0],
+		"parent":     parentParts[1],
+		"resource":   id.Name(),
+	})
+	return joinDN(dn, h.baseDN), nil
+}
+
+func (h *databaseResourceHandler) FromLDAP(entry *ldap.Entry) (*accountserver.Resource, error) {
+	if !isObjectClass(entry, "dbMysql") {
+		return nil, errWrongObjectClass
+	}
+
+	name := entry.GetAttributeValue("dbname")
+	rsrcID, parentID, err := makeDatabaseResourceID(entry.DN)
+	if err != nil {
+		return nil, err
+	}
+	return &accountserver.Resource{
+		ID:       rsrcID,
+		ParentID: parentID,
+		Name:     name,
+		Database: &accountserver.Database{
+			DBUser:            entry.GetAttributeValue("dbuser"),
+			CleartextPassword: entry.GetAttributeValue("clearPassword"),
+		},
+	}, nil
+}
+
+func (h *databaseResourceHandler) ToLDAP(rsrc *accountserver.Resource) []ldap.PartialAttribute {
+	return []ldap.PartialAttribute{
+		{Type: "objectClass", Vals: []string{"top", "dbMysql"}},
+		{Type: "dbname", Vals: s2l(rsrc.ID.Name())},
+		{Type: "dbuser", Vals: s2l(rsrc.Database.DBUser)},
+		{Type: "clearPassword", Vals: s2l(rsrc.Database.CleartextPassword)},
+	}
+}
+
+func (h *databaseResourceHandler) SearchQuery() *queryConfig {
+	return mustCompileQueryConfig(&queryConfig{
+		Base:   joinDN("ou=People", h.baseDN),
+		Filter: "(&(objectClass=dbMysql)(dbname=${resource}))",
+		Scope:  "sub",
+	})
+}
+
+func joinDN(parts ...string) string {
+	return strings.Join(parts, ",")
+}
diff --git a/backend/resources_test.go b/backend/resources_test.go
new file mode 100644
index 00000000..e7c5e216
--- /dev/null
+++ b/backend/resources_test.go
@@ -0,0 +1,48 @@
+package backend
+
+import (
+	"testing"
+
+	"github.com/go-test/deep"
+	"gopkg.in/ldap.v2"
+
+	"git.autistici.org/ai3/accountserver"
+)
+
+func TestEmailResource_FromLDAP(t *testing.T) {
+	entry := ldap.NewEntry(
+		"mail=test@investici.org,uid=test@investici.org,ou=People,dc=example,dc=com",
+		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"},
+		},
+	)
+
+	reg := newResourceRegistry()
+	reg.register(accountserver.ResourceTypeEmail, &emailResourceHandler{baseDN: "dc=example,dc=com"})
+
+	r, err := reg.FromLDAP(entry)
+	if err != nil {
+		t.Fatal("FromLDAP", err)
+	}
+
+	expected := &accountserver.Resource{
+		ID:            accountserver.NewResourceID("email", "test@investici.org", "test@investici.org"),
+		Name:          "test@investici.org",
+		Status:        "active",
+		Shard:         "host1",
+		OriginalShard: "host1",
+		Email: &accountserver.Email{
+			Aliases: []string{"test2@investici.org", "test3@investici.org"},
+			Maildir: "test/store",
+		},
+	}
+	if err := deep.Equal(r, expected); err != nil {
+		t.Fatalf("bad result: %v", err)
+	}
+}
diff --git a/backend/testdata/base.ldif b/backend/testdata/base.ldif
index 25893c71..fcbc2849 100644
--- a/backend/testdata/base.ldif
+++ b/backend/testdata/base.ldif
@@ -1,21 +1,16 @@
 
-dn: dc=example,dc=org
+dn: dc=example,dc=com
 objectclass: domain
 objectclass: top
 dc: example
 
-dn: ou=people,dc=example,dc=org
+dn: ou=People,dc=example,dc=com
 objectclass: top
 objectclass: organizationalUnit
-ou: people
+ou: People
 
-dn: uid=test@investici.org,ou=people,dc=example,dc=org
+dn: ou=Lists,dc=example,dc=com
 objectclass: top
-objectclass: person
-objectclass: organizationalPerson
-objectclass: inetOrgPerson
-cn: test@investici.org
-sn: test@investici.org
-uid: test@investici.org
-userPassword: koala
+objectclass: organizationalUnit
+ou: Lists
 
diff --git a/backend/testdata/test1.ldif b/backend/testdata/test1.ldif
new file mode 100644
index 00000000..308e5244
--- /dev/null
+++ b/backend/testdata/test1.ldif
@@ -0,0 +1,94 @@
+dn: uid=uno@investici.org,ou=People,dc=example,dc=com
+cn: uno@investici.org
+objectClass: top
+objectClass: person
+objectClass: posixAccount
+objectClass: shadowAccount
+objectClass: organizationalPerson
+objectClass: inetOrgPerson
+loginShell: /bin/false
+uidNumber: 19475
+shadowMax: 99999
+gidNumber: 2000
+gecos: uno@investici.org
+sn: Private
+homeDirectory: /var/empty
+uid: uno@investici.org
+givenName: Private
+shadowLastChange: 12345
+shadowWarning: 7
+preferredLanguage: it
+userPassword:: e2NyeXB0fSQ2JG1wVzdTZHZROG52OFJabE8kcFNqbm1hLi9CZDNIWU1hL29sMGZ
+ FRmZrS3pWRjAxUkkzMnpISEoxNnc2V2xaajFuSFVzMmd4ZXZIVDdoemdELnJ5Lk1BZWM3REptZVZ5
+ WEtkeDV0QTE=
+
+dn: mail=uno@investici.org,uid=uno@investici.org,ou=People,dc=example,dc=com
+status: active
+recoverQuestion:: dGkgc2VpIG1haSDDuMOgdHRvIG1hbGUgY2FkZW5kbyBkYSB1biBwYWxhenpv
+ IGRpIG90dG8gcGlhbmk/
+objectClass: top
+objectClass: virtualMailUser
+userPassword:: e2NyeXB0fSQ2JG1wVzdTZHZROG52OFJabE8kcFNqbm1hLi9CZDNIWU1hL29sMGZ
+ FRmZrS3pWRjAxUkkzMnpISEoxNnc2V2xaajFuSFVzMmd4ZXZIVDdoemdELnJ5Lk1BZWM3REptZVZ5
+ WEtkeDV0QTE=
+uidNumber: 19475
+host: host2
+mailAlternateAddress: uno@anche.no
+recoverAnswer: {crypt}$1$wtEa4TKB$lxeyenkQ1yfxECn7WVQQ0/
+gidNumber: 2000
+mail: uno@investici.org
+creationDate: 2002-05-07
+mailMessageStore: investici.org/uno/
+originalHost: host2
+
+dn: ftpname=uno,uid=uno@investici.org,ou=People,dc=example,dc=com
+status: active
+givenName: Private
+cn: uno
+objectClass: top
+objectClass: person
+objectClass: posixAccount
+objectClass: shadowAccount
+objectClass: organizationalPerson
+objectClass: inetOrgPerson
+objectClass: ftpAccount
+loginShell: /bin/false
+shadowWarning: 7
+uidNumber: 19475
+host: host2
+shadowMax: 99999
+ftpname: uno
+gidNumber: 33
+gecos: FTP Account for uno@investici.org
+sn: Private
+homeDirectory: /home/users/investici.org/uno
+uid: uno
+creationDate: 01-08-2013
+shadowLastChange: 12345
+originalHost: host2
+userPassword:: e2NyeXB0fSQ2JElDYkx1WTI3QWl6bC5FeEgkUDhOZHJ3VEtxZ2UwQUp3QW9oNE1
+ EYlUxU3EySGtuRkF1cEx2RUI0U28waEw5NWtpZ3dIeXQuQnYxS0J5SFM2MXd6RnZuLnJsMEN4eFpx
+ RVgzUnVxbDE=
+
+dn: alias=uno,uid=uno@investici.org,ou=People,dc=example,dc=com
+status: active
+parentSite: autistici.org
+objectClass: top
+objectClass: subSite
+alias: uno
+host: host2
+documentRoot: /home/users/investici.org/uno/html-uno
+creationDate: 01-08-2013
+originalHost: host2
+statsId: 2191
+
+dn: dbname=unodb,alias=uno,uid=uno@investici.org,ou=People,dc=example,dc=com
+status: active
+clearPassword: password
+objectClass: top
+objectClass: dbMysql
+dbname: unodb
+dbuser: unodb
+host: host2
+creationDate: 01-08-2013
+originalHost: host2
diff --git a/backend/tx.go b/backend/tx.go
index ccc88bf9..98e04d18 100644
--- a/backend/tx.go
+++ b/backend/tx.go
@@ -2,6 +2,7 @@ package backend
 
 import (
 	"context"
+	"log"
 	"strings"
 
 	"gopkg.in/ldap.v2"
@@ -65,6 +66,9 @@ func (tx *ldapTX) search(ctx context.Context, req *ldap.SearchRequest) (*ldap.Se
 // setAttr modifies a single attribute of an object. To delete an
 // attribute, pass an empty list of values.
 func (tx *ldapTX) setAttr(dn, attr string, values ...string) {
+	if dn == "" {
+		panic("empty dn in setAttr!")
+	}
 	tx.changes = append(tx.changes, ldapAttr{dn: dn, attr: attr, values: values})
 }
 
@@ -91,6 +95,7 @@ func (tx *ldapTX) Commit(ctx context.Context) error {
 		if isEmptyModifyRequest(mr) {
 			continue
 		}
+		log.Printf("issuing ModifyRequest: %+v", mr)
 		if err := tx.conn.Modify(ctx, mr); err != nil {
 			return err
 		}
diff --git a/types.go b/types.go
index 6a65b919..c4dd6859 100644
--- a/types.go
+++ b/types.go
@@ -38,7 +38,7 @@ type User struct {
 func (u *User) GetResourcesByType(resourceType string) []*Resource {
 	var out []*Resource
 	for _, r := range u.Resources {
-		if r.Type == resourceType {
+		if r.ID.Type() == resourceType {
 			out = append(out, r)
 		}
 	}
@@ -47,7 +47,7 @@ func (u *User) GetResourcesByType(resourceType string) []*Resource {
 
 func (u *User) GetSingleResourceByType(resourceType string) *Resource {
 	for _, r := range u.Resources {
-		if r.Type == resourceType {
+		if r.ID.Type() == resourceType {
 			return r
 		}
 	}
@@ -111,6 +111,7 @@ const (
 	ResourceTypeEmail       = "email"
 	ResourceTypeMailingList = "list"
 	ResourceTypeWebsite     = "web"
+	ResourceTypeDomain      = "domain"
 	ResourceTypeDAV         = "dav"
 	ResourceTypeDatabase    = "db"
 )
@@ -204,16 +205,16 @@ func ParseResourceID(s string) (ResourceID, error) {
 // some common properties related to sharding and state, plus
 // type-specific attributes.
 type Resource struct {
-	// ID is a unique primary key in the resources space,
-	// consisting of 'type/name'.
+	// ID is a unique primary key in the resources space, with a
+	// path-like representation. It must make sense to the
+	// database backend and be reversible (i.e. there must be a
+	// bidirectional mapping between database objects and resource
+	// IDs).
 	ID ResourceID `json:"id"`
 
 	// Name of the resource, used for display purposes.
 	Name string `json:"name"`
 
-	// Type of the resource.
-	Type string `json:"type"`
-
 	// Optional attribute for hierarchical resources.
 	ParentID ResourceID `json:"parent_id,omitempty"`
 
@@ -284,9 +285,9 @@ type WebDAV struct {
 // Website resource attributes.
 type Website struct {
 	URL          string            `json:"url"`
-	DisplayName  string            `json:"display_name"`
+	ParentDomain string            `json:"parent_domain,omitempty"`
 	AcceptMail   bool              `json:"accept_mail"`
-	Options      []string          `json:"options"`
+	Options      []string          `json:"options,omitempty"`
 	Categories   []string          `json:"categories,omitempty"`
 	Description  map[string]string `json:"description,omitempty"`
 	QuotaUsage   int               `json:"quota_usage"`
-- 
GitLab