diff --git a/API.md b/API.md
index 884c69bb432cb2142a5cbc78ee9c6351c6d076f4..61219b8071246eff1643aa593925b1e2e2d92d1b 100644
--- a/API.md
+++ b/API.md
@@ -130,3 +130,157 @@ Actions:
 ### web hosting (website / db / FTP account or equivalent)
 
 ...
+
+
+# API Reference
+
+## User endpoints
+
+The following API endpoints invoke operations on an individual
+user. Access is allowed for admins, and for the user itself.
+
+Most requests dealing with encryption keys are so-called *privileged*
+requests, and will require the user's password in the *cur_password*
+parameter in order to decrypt the existing encryption keys.
+
+### `/api/user/get`
+
+Retrieve information about a user and all its associated resources.
+
+Request parameters:
+
+* `username` - user to fetch
+* `sso` - SSO ticket
+
+### `/api/user/change_password`
+
+Change the primary authentication password for a user. This operation
+will update the user's storage encryption keys, or initialize them if
+they do not exist.
+
+Request parameters:
+
+* `username` - name of the user
+* `sso` - SSO ticket
+* `cur_password` - current valid password for the user
+* `password` - new password (unencrypted)
+
+### `/api/user/set_password_recovery_hint`
+
+Sets the secondary authentication password (a hint / response pair,
+used to recover the primary credentials) for a user. This operation
+will update the user's storage encryption keys, or initialize them if
+they do not exist yet.
+
+Request parameters:
+
+* `username` - name of the user
+* `sso` - SSO ticket
+* `cur_password` - current valid password for the user
+* `hint` - the secondary authentication hint
+* `response` - the secondary authentication response (effectively a
+  password, unencrypted)
+
+### `/api/user/enable_otp`
+
+Enable TOTP for a user. The server can generate a new TOTP secret if
+necessary, or it can be generated by the caller (usually better as it
+allows for a better validation UX).
+
+Request parameters:
+
+* `username` - name of the user
+* `sso` - SSO ticket
+* `totp_secret` - new TOTP secret (optional)
+
+### `/api/user/disable_otp`
+
+Disable TOTP for a user. Existing 2FA credentials will be wiped as
+well.
+
+Request parameters:
+
+* `username` - name of the user
+* `sso` - SSO ticket
+
+### `/api/user/create_app_specific_password`
+
+Create a new application-specific password. 2FA must already be
+enabled for the user. A new random password will be generated by the
+server and returned in the response. A new copy of the encryption key
+will be encrypted with the new application-specific password.
+
+ASPs are identified by a unique random ID that is also automatically
+generated by the server.
+
+Request parameters:
+
+* `username` - name of the user
+* `sso` - SSO ticket
+* `cur_password` - current valid password for the user
+* `service` - service that the password should be valid for
+* `notes` - a user-controlled comment about the client
+
+### `/api/user/delete_app_specific_password`
+
+Delete an application-specific password (and the associated encryption
+key).
+
+Request parameters:
+
+* `username` - name of the user
+* `sso` - SSO ticket
+* `asp_id` - ID of the app-specific password
+
+
+## Resource endpoints
+
+These API endpoints manipulate individual resources regardless of
+which user they belong to. Access is normally granted to admins and to
+the user that owns a resource, but some operations are restricted to
+admins only.
+
+### `/api/resource/enable`
+
+Enable a resource (admin-only).
+
+Request parameters:
+
+* `resource_id` - resource ID
+* `sso` - SSO ticket
+* `comment` - notes on the operation
+
+### `/api/resource/disable`
+
+Disable a resource (admin-only).
+
+Request parameters:
+
+* `resource_id` - resource ID
+* `sso` - SSO ticket
+* `comment` - notes on the operation
+
+### `/api/resource/move`
+
+Move a resource between shards (admin-only).
+
+Resources that are part of a group (for instance websites and DAV
+accounts) are moved together, so this request might end up moving more
+than one resource.
+
+Request parameters:
+
+* `resource_id` - resource ID
+* `sso` - SSO ticket
+* `shard` - new shard
+
+### `/api/resource/create`
+
+Create one or more resources associated with a user. Note that if
+creating multiple resources, they must all belong to the same user.
+
+Request parameters:
+
+* `sso` - SSO ticket
+* `resources` - list of resource objects to create
+
diff --git a/DATAMODEL.md b/DATAMODEL.md
new file mode 100644
index 0000000000000000000000000000000000000000..f72cad566f8fa7df70a80da352da25bcf15314c6
--- /dev/null
+++ b/DATAMODEL.md
@@ -0,0 +1,46 @@
+A few notes on the data model
+======
+
+The data model used by the accountserver tries to be straightforward
+and simple, but there are numerous aspects about the assumed semantics
+that require further discussion.
+
+At its core, it represents *users* and their associated
+*resources*. Resources can represent accounts on services of different
+type, and can be nested if there is a direct and relevant hierarchical
+relation.
+
+## Authentication
+
+The major issue with the LDAP backend is that it's not easy to do
+joins (you need to do two separate queries, and most open source
+clients aren't even coded to do that). This makes it hard to go, for
+example, from the email resource object to the main user account
+object.
+
+The issue of authentication is made complex by the choice of specific
+services and desired UX that we have adopted. Specifically, the
+decision of having the e-mail coincide with the primary user identity
+(along with the availability of web-based single sign-on), and the UX
+decision of presenting a single "authentication control surface" to
+the user (so that, for instance, you don't have to separately set up
+2FA for the main user account and your email) determine the need to
+unify authentication details for the main user object and the email
+resource(s).
+
+In practice, this means that the API offers its authentication-related
+methods on the user object rather than on the email resource. While
+the implementation on a SQL backend would be straightforward
+(credentials would simply be fields, or subtables, of the *user*
+table), in the LDAP backend we are going to rely on denormalization
+(i.e. copying the same attribute to multiple objects) and careful
+splitting of some attributes to where they are used. For an example of
+the latter, consider 2FA, where the split between interactive and
+non-interactive clients allows us to:
+
+* put the TOTP secret on the user object, because that's used by the
+  interactive authentication service (the single sign-on login server
+  application);
+* put application-specific passwords (used by non-interactive clients)
+  on the email resource LDAP objects, which is where the email service
+  authenticates its users. Same for the storage encryption keys.
diff --git a/README.md b/README.md
index 6ad021b049b25abf36e0945e3205667c41f77b30..939b2ac619279f3dea0be89e856cb534f5c520a0 100644
--- a/README.md
+++ b/README.md
@@ -50,3 +50,18 @@ esprimendo una relazione di associazione di qualche tipo (come ad
 esempio tra siti web e database MySQL).
 
 Lo schema è definito esplicitamente in [types.go](types.go).
+
+
+# Testing
+
+Per testare questo attrezzo purtroppo serve Java (basta un JRE, il
+runtime environment). Questo perché non è affatto facile implementare
+un server LDAP per i test, quindi si è scelto di usarne uno esistente:
+in particolare un'implementazione Java... su un sistema Debian:
+
+```
+sudo apt install default-jre-headless
+```
+
+dovrebbe bastare (oltre a *golang-go* ovviamente) per poter eseguire i
+test con successo.
diff --git a/actions.go b/actions.go
index 3914cca5dfae5062fd329dc41ef8af0b840adb2e..440e40c1bfa2a6dc418fca939bb24bc09acfc41d 100644
--- a/actions.go
+++ b/actions.go
@@ -5,160 +5,53 @@ import (
 	"crypto/rand"
 	"encoding/base64"
 	"errors"
+	"fmt"
+	"log"
 
 	"git.autistici.org/ai3/go-common/pwhash"
-	"git.autistici.org/id/go-sso"
-	"git.autistici.org/id/keystore/userenckey"
 	"github.com/pquerna/otp/totp"
 )
 
-// Backend user database interface.
-//
-// All methods share similar semantics: Get methods will return nil if
-// the requested object is not found, and only return an error in case
-// of trouble reaching the backend itself.
-//
-// The backend enforces strict public/private data separation by
-// having Get methods return public objects (as defined in types.go),
-// and using specialized methods to modify the private
-// (authentication-related) attributes.
-//
-// We might add more sophisticated resource query methods later, as
-// admin-level functionality.
-//
-type Backend interface {
-	GetUser(context.Context, string) (*User, error)
-	GetResource(context.Context, string, string) (*Resource, error)
-	UpdateResource(context.Context, string, *Resource) error
-	SetUserPassword(context.Context, *User, string) error
-	SetResourcePassword(context.Context, string, *Resource, string) error
-	GetUserEncryptionKeys(context.Context, *User) ([]*UserEncryptionKey, error)
-	SetUserEncryptionKeys(context.Context, *User, []*UserEncryptionKey) error
-	SetUserEncryptionPublicKey(context.Context, *User, []byte) error
-	SetApplicationSpecificPassword(context.Context, *User, *AppSpecificPasswordInfo, string) error
-	DeleteApplicationSpecificPassword(context.Context, *User, string) error
-	SetUserTOTPSecret(context.Context, *User, string) error
-	DeleteUserTOTPSecret(context.Context, *User) error
-
-	HasAnyResource(context.Context, []string) (bool, error)
-}
-
-// AccountService implements the business logic and high-level
-// functionality of the user accounts management service.
-type AccountService struct {
-	backend Backend
-
-	validator     sso.Validator
-	ssoService    string
-	ssoGroups     []string
-	ssoAdminGroup string
-
-	dataValidators      map[string]ValidatorFunc
-	adminDataValidators map[string]ValidatorFunc
-}
-
-func NewAccountService(backend Backend, config *Config) (*AccountService, error) {
-	ssoValidator, err := config.ssoValidator()
-	if err != nil {
-		return nil, err
-	}
+// RequestBase contains parameters shared by all request types.
+type RequestBase struct {
+	Username string `json:"username"`
+	SSO      string `json:"sso"`
 
-	return newAccountServiceWithSSO(backend, config, ssoValidator), nil
+	// Optional comment, will end up in audit logs.
+	Comment string `json:"comment,omitempty"`
 }
 
-func newAccountServiceWithSSO(backend Backend, config *Config, ssoValidator sso.Validator) *AccountService {
-	s := &AccountService{
-		backend:       backend,
-		validator:     ssoValidator,
-		ssoService:    config.SSO.Service,
-		ssoGroups:     config.SSO.Groups,
-		ssoAdminGroup: config.SSO.AdminGroup,
-	}
+type userCtxKeyType int
 
-	validationConfig := config.validationConfig()
-	domainBackend := config.domainBackend()
-	s.dataValidators = map[string]ValidatorFunc{
-		ResourceTypeEmail:       validHostedEmail(validationConfig, domainBackend, backend),
-		ResourceTypeMailingList: validHostedMailingList(validationConfig, domainBackend, backend),
-	}
-
-	return s
-}
+var userCtxKey userCtxKeyType
 
-func (s *AccountService) isAdmin(tkt *sso.Ticket) bool {
-	for _, g := range tkt.Groups {
-		if g == s.ssoAdminGroup {
-			return true
-		}
+func userFromContext(ctx context.Context) string {
+	s, ok := ctx.Value(userCtxKey).(string)
+	if ok {
+		return s
 	}
-	return false
+	return ""
 }
 
-var (
-	ErrUnauthorized     = errors.New("unauthorized")
-	ErrUserNotFound     = errors.New("user not found")
-	ErrResourceNotFound = errors.New("resource not found")
-)
-
-func (s *AccountService) authorizeAdmin(ctx context.Context, username, ssoToken string) (*User, error) {
-	// Validate the SSO ticket.
-	tkt, err := s.validator.Validate(ssoToken, "", s.ssoService, s.ssoGroups)
-	if err != nil {
-		return nil, newAuthError(err)
-	}
+type commentCtxKeyType int
 
-	// Requests are allowed if the SSO ticket corresponds to an admin, or if
-	// it identifies the same user that we're querying.
-	if !s.isAdmin(tkt) {
-		return nil, newAuthError(ErrUnauthorized)
-	}
+var commentCtxKey commentCtxKeyType
 
-	user, err := s.backend.GetUser(ctx, username)
-	if err != nil {
-		return nil, newBackendError(err)
+func commentFromContext(ctx context.Context) string {
+	s, ok := ctx.Value(commentCtxKey).(string)
+	if ok {
+		return s
 	}
-	if user == nil {
-		return nil, ErrUserNotFound
-	}
-	return user, nil
+	return ""
 }
 
-func (s *AccountService) authorizeUser(ctx context.Context, username, ssoToken string) (*User, error) {
-	// First, check that the username matches the SSO ticket
-	// username (or that the SSO ticket has admin permissions).
-	tkt, err := s.validator.Validate(ssoToken, "", s.ssoService, s.ssoGroups)
-	if err != nil {
-		return nil, newAuthError(err)
-	}
-
-	// Requests are allowed if the SSO ticket corresponds to an admin, or if
-	// it identifies the same user that we're querying.
-	if !s.isAdmin(tkt) && tkt.User != username {
-		return nil, newAuthError(ErrUnauthorized)
-	}
-
-	user, err := s.backend.GetUser(ctx, username)
-	if err != nil {
-		return nil, newBackendError(err)
-	}
-	if user == nil {
-		return nil, ErrUserNotFound
+// NewContext returns a new Context with some request-related values set.
+func (r RequestBase) NewContext(ctx context.Context) context.Context {
+	ctx = context.WithValue(ctx, userCtxKey, r.Username)
+	if r.Comment != "" {
+		ctx = context.WithValue(ctx, commentCtxKey, r.Comment)
 	}
-	return user, nil
-}
-
-// Extended version of authorizeUser that also directly checks the
-// user password. Used for account-privileged operations related to
-// credential manipulation.
-func (s *AccountService) authorizeUserWithPassword(ctx context.Context, username, ssoToken, password string) (*User, error) {
-	// TODO: call out to the auth-server?
-	return s.authorizeUser(ctx, username, ssoToken)
-}
-
-// RequestBase contains parameters shared by all request types.
-type RequestBase struct {
-	Username string `json:"username"`
-	SSO      string `json:"sso"`
+	return ctx
 }
 
 // PrivilegedRequestBase extends RequestBase with the user password,
@@ -168,251 +61,333 @@ type PrivilegedRequestBase struct {
 	CurPassword string `json:"cur_password"`
 }
 
+// ResourceRequestBase is the base type for resource-level requests.
+type ResourceRequestBase struct {
+	ResourceID ResourceID `json:"resource_id"`
+	SSO        string     `json:"sso"`
+
+	// Optional comment, will end up in audit logs.
+	Comment string `json:"comment,omitempty"`
+}
+
+// NewContext returns a new Context with some request-related values set.
+func (r ResourceRequestBase) NewContext(ctx context.Context) context.Context {
+	if u := r.ResourceID.User(); u != "" {
+		ctx = context.WithValue(ctx, userCtxKey, u)
+	}
+	if r.Comment != "" {
+		ctx = context.WithValue(ctx, commentCtxKey, r.Comment)
+	}
+	return ctx
+}
+
+// GetUserRequest is the request type for AccountService.GetUser().
 type GetUserRequest struct {
 	RequestBase
 }
 
 // GetUser returns public information about a user.
-func (s *AccountService) GetUser(ctx context.Context, req *GetUserRequest) (*User, error) {
-	return s.authorizeUser(ctx, req.Username, req.SSO)
+func (s *AccountService) GetUser(ctx context.Context, tx TX, req *GetUserRequest) (resp *User, err error) {
+	err = s.handleUserRequest(ctx, tx, req, s.authUser(req.RequestBase), func(ctx context.Context, user *User) error {
+		resp = user
+		return nil
+	})
+	return
 }
 
 // setResourceStatus sets the status of a single resource (shared
 // logic between enable / disable resource methods).
-func (s *AccountService) setResourceStatus(ctx context.Context, username, resourceID, status string) error {
-	r, err := s.backend.GetResource(ctx, username, resourceID)
-	if err != nil {
-		return newBackendError(err)
-	}
-	if r == nil {
-		return ErrResourceNotFound
-	}
+func (s *AccountService) setResourceStatus(ctx context.Context, tx TX, r *Resource, status string) error {
 	r.Status = status
-	if err := s.backend.UpdateResource(ctx, username, r); err != nil {
+	if err := tx.UpdateResource(ctx, r); err != nil {
 		return newBackendError(err)
 	}
+	s.audit.Log(ctx, r.ID, fmt.Sprintf("status set to %s", status))
 	return nil
 }
 
+// DisableResourceRequest is the request type for AccountService.DisableResource().
 type DisableResourceRequest struct {
-	RequestBase
-	ResourceID string `json:"resource_id"`
+	ResourceRequestBase
 }
 
 // DisableResource disables a resource belonging to the user.
-func (s *AccountService) DisableResource(ctx context.Context, req *DisableResourceRequest) error {
-	if _, err := s.authorizeUser(ctx, req.Username, req.SSO); err != nil {
-		return err
-	}
-	return s.setResourceStatus(ctx, req.Username, req.ResourceID, ResourceStatusInactive)
+func (s *AccountService) DisableResource(ctx context.Context, tx TX, req *DisableResourceRequest) error {
+	return s.handleResourceRequest(ctx, tx, req, s.authResource(req.ResourceRequestBase), func(ctx context.Context, r *Resource) error {
+		return s.setResourceStatus(ctx, tx, r, ResourceStatusInactive)
+	})
 }
 
+// EnableResourceRequest is the request type for AccountService.EnableResource().
 type EnableResourceRequest struct {
-	RequestBase
-	ResourceID string `json:"resource_id"`
+	ResourceRequestBase
 }
 
 // EnableResource enables a resource belonging to the user.
-func (s *AccountService) EnableResource(ctx context.Context, req *EnableResourceRequest) error {
-	if _, err := s.authorizeUser(ctx, req.Username, req.SSO); err != nil {
-		return err
-	}
-	return s.setResourceStatus(ctx, req.Username, req.ResourceID, ResourceStatusActive)
+func (s *AccountService) EnableResource(ctx context.Context, tx TX, req *EnableResourceRequest) error {
+	return s.handleResourceRequest(ctx, tx, req, s.authResource(req.ResourceRequestBase), func(ctx context.Context, r *Resource) error {
+		return s.setResourceStatus(ctx, tx, r, ResourceStatusActive)
+	})
 }
 
+// ChangeUserPasswordRequest is the request type for AccountService.ChangeUserPassword().
 type ChangeUserPasswordRequest struct {
 	PrivilegedRequestBase
 	Password string `json:"password"`
 }
 
-func (r *ChangeUserPasswordRequest) Validate() error {
-	if r.Password == "" {
-		return errors.New("empty 'password' attribute")
-	}
-	return nil
+// Validate the request.
+func (r *ChangeUserPasswordRequest) Validate(ctx context.Context, s *AccountService) error {
+	return s.fieldValidators.password(ctx, r.Password)
 }
 
 // ChangeUserPassword updates a user's password. It will also take
 // care of re-encrypting the user encryption key, if present.
-func (s *AccountService) ChangeUserPassword(ctx context.Context, req *ChangeUserPasswordRequest) error {
-	user, err := s.authorizeUserWithPassword(ctx, req.Username, req.SSO, req.CurPassword)
-	if err != nil {
-		return err
-	}
+func (s *AccountService) ChangeUserPassword(ctx context.Context, tx TX, req *ChangeUserPasswordRequest) error {
+	return s.handleUserRequest(ctx, tx, req, s.authUserWithPassword(req.PrivilegedRequestBase), func(ctx context.Context, user *User) error {
+		return s.changeUserPasswordAndUpdateEncryptionKeys(ctx, tx, user, req.CurPassword, req.Password)
+	})
+}
 
-	if err = req.Validate(); err != nil {
-		return newRequestError(err)
-	}
+// PasswordRecoveryRequest is the request type for AccountService.RecoverPassword().
+// It is not authenticated with SSO.
+type PasswordRecoveryRequest struct {
+	Username         string `json:"username"`
+	RecoveryPassword string `json:"recovery_password"`
+	Password         string `json:"password"`
+}
 
-	// If the user does not yet have an encryption key, generate one now.
-	if !user.HasEncryptionKeys {
-		err = s.initializeUserEncryptionKeys(ctx, user, req.CurPassword)
-	} else {
-		err = s.updateUserEncryptionKeys(ctx, user, req.CurPassword, req.Password, UserEncryptionKeyMainID)
-	}
+// Validate the request.
+func (r *PasswordRecoveryRequest) Validate(ctx context.Context, s *AccountService) error {
+	return s.fieldValidators.password(ctx, r.Password)
+}
+
+// RecoverPassword lets users reset their password by providing
+// secondary credentials, which we authenticate ourselves.
+//
+// TODO: call out to auth-server for secondary authentication?
+func (s *AccountService) RecoverPassword(ctx context.Context, tx TX, req *PasswordRecoveryRequest) error {
+	user, err := getUserOrDie(ctx, tx, req.Username)
 	if err != nil {
 		return err
 	}
+	// TODO: authenticate with the secret recovery password.
+	ctx = context.WithValue(ctx, authUserCtxKey, req.Username)
 
-	// Set the encrypted password attribute on the user and email resources.
-	encPass := pwhash.Encrypt(req.Password)
-	if err := s.backend.SetUserPassword(ctx, user, encPass); err != nil {
-		return newBackendError(err)
-	}
-	for _, r := range user.GetResourcesByType(ResourceTypeEmail) {
-		if err := s.backend.SetResourcePassword(ctx, user.Name, r, encPass); err != nil {
+	return s.withRequest(ctx, tx, req, user, func(ctx context.Context) error {
+		// Change the user password (the recovery password does not change).
+		return s.changeUserPasswordAndUpdateEncryptionKeys(ctx, tx, user, req.RecoveryPassword, req.Password)
+	})
+}
+
+// ResetPasswordRequest is the request type for AccountService.ResetPassword().
+type ResetPasswordRequest struct {
+	RequestBase
+	Password string `json:"password"`
+}
+
+// Validate the request.
+func (r *ResetPasswordRequest) Validate(ctx context.Context, s *AccountService) error {
+	return s.fieldValidators.password(ctx, r.Password)
+}
+
+// ResetPassword is an admin operation to forcefully reset the
+// password for an account. The user will lose access to all stored
+// email (because the encryption keys will be reset) and to 2FA.
+func (s *AccountService) ResetPassword(ctx context.Context, tx TX, req *ResetPasswordRequest) error {
+	return s.handleUserRequest(ctx, tx, req, s.authAdmin(req.RequestBase), func(ctx context.Context, user *User) error {
+		// Disable 2FA.
+		if err := s.disable2FA(ctx, tx, user); err != nil {
+			return err
+		}
+
+		// Reset encryption keys and set the new password.
+		return s.changeUserPasswordAndResetEncryptionKeys(ctx, tx, user, req.Password)
+	})
+}
+
+// SetPasswordRecoveryHintRequest is the request type for
+// AccountService.SetPasswordRecoveryHint().
+type SetPasswordRecoveryHintRequest struct {
+	PrivilegedRequestBase
+	Hint     string `json:"recovery_hint"`
+	Response string `json:"recovery_response"`
+}
+
+// Validate the request.
+func (r *SetPasswordRecoveryHintRequest) Validate(ctx context.Context, s *AccountService) error {
+	return s.fieldValidators.password(ctx, r.Response)
+}
+
+// SetPasswordRecoveryHint lets users set the password recovery hint
+// and expected response (secondary password).
+func (s *AccountService) SetPasswordRecoveryHint(ctx context.Context, tx TX, req *SetPasswordRecoveryHintRequest) error {
+	return s.handleUserRequest(ctx, tx, req, s.authUserWithPassword(req.PrivilegedRequestBase), func(ctx context.Context, user *User) error {
+		// If the encryption keys are not set up yet, use the
+		// CurPassword to initialize them.
+		keys, decrypted, err := s.readOrInitializeEncryptionKeys(ctx, tx, user, req.CurPassword, req.CurPassword)
+		if err != nil {
+			return err
+		}
+
+		keys, err = updateEncryptionKey(keys, decrypted, UserEncryptionKeyRecoveryID, req.Response)
+		if err != nil {
+			return err
+		}
+
+		if err := tx.SetUserEncryptionKeys(ctx, user, keys); err != nil {
 			return newBackendError(err)
 		}
-	}
 
-	return nil
+		encResponse := pwhash.Encrypt(req.Response)
+		return tx.SetPasswordRecoveryHint(ctx, user, req.Hint, encResponse)
+	})
 }
 
-// Initialize the user encryption key list, by creating a new "main" key
-// encrypted with the given password (which must be the primary password for the
-// user).
-func (s *AccountService) initializeUserEncryptionKeys(ctx context.Context, user *User, curPassword string) error {
-	// Generate a new key pair.
-	pub, priv, err := userenckey.GenerateKey()
+// Change the user password and update encryption keys, provided we
+// have a password that we can use to decrypt them first.
+func (s *AccountService) changeUserPasswordAndUpdateEncryptionKeys(ctx context.Context, tx TX, user *User, oldPassword, newPassword string) error {
+	// If the user does not yet have an encryption key, generate one now.
+	var err error
+
+	keys, decrypted, err := s.readOrInitializeEncryptionKeys(ctx, tx, user, oldPassword, newPassword)
 	if err != nil {
 		return err
 	}
 
-	// Encrypt the private key with the password.
-	enc, err := userenckey.Encrypt(priv, []byte(curPassword))
+	keys, err = updateEncryptionKey(keys, decrypted, UserEncryptionKeyMainID, newPassword)
 	if err != nil {
 		return err
 	}
-	keys := []*UserEncryptionKey{
-		&UserEncryptionKey{
-			ID:  UserEncryptionKeyMainID,
-			Key: enc,
-		},
-	}
 
-	// Update the backend database.
-	if err := s.backend.SetUserEncryptionKeys(ctx, user, keys); err != nil {
+	if err := tx.SetUserEncryptionKeys(ctx, user, keys); err != nil {
 		return newBackendError(err)
 	}
-	if err := s.backend.SetUserEncryptionPublicKey(ctx, user, pub); err != nil {
+
+	// Set the encrypted password attribute on the user (will set it on emails too).
+	encPass := pwhash.Encrypt(newPassword)
+	if err := tx.SetUserPassword(ctx, user, encPass); err != nil {
 		return newBackendError(err)
 	}
 
+	s.audit.Log(ctx, ResourceID{}, "password changed")
+
 	return nil
 }
 
-// Re-encrypt the specified user encryption key with newPassword. For this
-// operation to succeed, we must be able to decrypt one of the keys (not
-// necessarily the same one) with curPassword.
-func (s *AccountService) updateUserEncryptionKeys(ctx context.Context, user *User, curPassword, newPassword, keyID string) error {
-	keys, err := s.backend.GetUserEncryptionKeys(ctx, user)
+// Change the user password and reset all encryption keys. Existing email
+// won't be readable anymore. Existing 2FA credentials will be deleted.
+func (s *AccountService) changeUserPasswordAndResetEncryptionKeys(ctx context.Context, tx TX, user *User, newPassword string) error {
+	// Calling initialize will wipe the current keys and replace
+	// them with a new one.
+	keys, _, err := s.initializeEncryptionKeys(ctx, tx, user, newPassword)
 	if err != nil {
-		return newBackendError(err)
+		return err
 	}
-	keys, err = reEncryptUserKeys(keys, curPassword, newPassword, keyID)
-	if err != nil {
-		return newRequestError(err)
+
+	if err := tx.SetUserEncryptionKeys(ctx, user, keys); err != nil {
+		return newBackendError(err)
 	}
-	if err := s.backend.SetUserEncryptionKeys(ctx, user, keys); err != nil {
+
+	// Set the encrypted password attribute on the user (will set it on emails too).
+	encPass := pwhash.Encrypt(newPassword)
+	if err := tx.SetUserPassword(ctx, user, encPass); err != nil {
 		return newBackendError(err)
 	}
+
+	s.audit.Log(ctx, ResourceID{}, "password reset")
+
 	return nil
 }
 
-// Decode the user encyrption key using the given password, then generate a new
-// list of encryption keys by replacing the specified encryption key with one
-// encrypted with the given password (or adding it if it does not exist).
-func reEncryptUserKeys(keys []*UserEncryptionKey, curPassword, newPassword, keyID string) ([]*UserEncryptionKey, error) {
-	// userenckey.Decrypt wants a slice of []byte.
-	var rawKeys [][]byte
-	for _, k := range keys {
-		rawKeys = append(rawKeys, k.Key)
-	}
-	decrypted, err := userenckey.Decrypt(rawKeys, []byte(curPassword))
-	if err != nil {
-		return nil, err
-	}
-	encrypted, err := userenckey.Encrypt(decrypted, []byte(newPassword))
-	if err != nil {
-		return nil, err
+// Disable 2FA for a user account.
+func (s *AccountService) disable2FA(ctx context.Context, tx TX, user *User) error {
+	// Disable OTP.
+	if err := tx.DeleteUserTOTPSecret(ctx, user); err != nil {
+		return newBackendError(err)
 	}
 
-	var keysOut []*UserEncryptionKey
-	for _, key := range keys {
-		if key.ID != keyID {
-			keysOut = append(keysOut, key)
+	// Wipe all app-specific passwords.
+	for _, asp := range user.AppSpecificPasswords {
+		if err := tx.DeleteApplicationSpecificPassword(ctx, user, asp.ID); err != nil {
+			return newBackendError(err)
 		}
 	}
-	keysOut = append(keysOut, &UserEncryptionKey{
-		ID:  keyID,
-		Key: encrypted,
-	})
-	return keysOut, nil
+	return nil
 }
 
+// CreateApplicationSpecificPasswordRequest is the request type for
+// AccountService.CreateApplicationSpecificPassword().
 type CreateApplicationSpecificPasswordRequest struct {
 	PrivilegedRequestBase
 	Service string `json:"service"`
-	Comment string `json:"comment"`
+	Notes   string `json:"notes"`
 }
 
-func (r *CreateApplicationSpecificPasswordRequest) Validate() error {
+// Validate the request.
+func (r *CreateApplicationSpecificPasswordRequest) Validate(_ context.Context, _ *AccountService) error {
 	if r.Service == "" {
 		return errors.New("empty 'service' attribute")
 	}
 	return nil
 }
 
+// CreateApplicationSpecificPasswordResponse is the response type for
+// AccountService.CreateApplicationSpecificPassword().
 type CreateApplicationSpecificPasswordResponse struct {
 	Password string `json:"password"`
 }
 
 // CreateApplicationSpecificPassword will generate a new
 // application-specific password for the given service.
-func (s *AccountService) CreateApplicationSpecificPassword(ctx context.Context, req *CreateApplicationSpecificPasswordRequest) (*CreateApplicationSpecificPasswordResponse, error) {
-	user, err := s.authorizeUserWithPassword(ctx, req.Username, req.SSO, req.CurPassword)
-	if err != nil {
-		return nil, err
-	}
-
-	if err := req.Validate(); err != nil {
-		return nil, newRequestError(err)
-	}
-
-	// No application-specific passwords unless 2FA is enabled.
-	if !user.Has2FA {
-		return nil, newRequestError(errors.New("2FA is not enabled for this user"))
-	}
+func (s *AccountService) CreateApplicationSpecificPassword(ctx context.Context, tx TX, req *CreateApplicationSpecificPasswordRequest) (*CreateApplicationSpecificPasswordResponse, error) {
+	var resp CreateApplicationSpecificPasswordResponse
+	err := s.handleUserRequest(ctx, tx, req, s.authUserWithPassword(req.PrivilegedRequestBase), func(ctx context.Context, user *User) error {
+		// No application-specific passwords unless 2FA is enabled.
+		if !user.Has2FA {
+			return newRequestError(errors.New("2FA is not enabled for this user"))
+		}
 
-	// Create a new application-specific password and set it in
-	// the database. We don't need to update the User object as
-	// we're not reusing it.
-	asp := &AppSpecificPasswordInfo{
-		ID:      randomAppSpecificPasswordID(),
-		Service: req.Service,
-		Comment: req.Comment,
-	}
-	password := randomAppSpecificPassword()
-	encPass := pwhash.Encrypt(password)
-	if err := s.backend.SetApplicationSpecificPassword(ctx, user, asp, encPass); err != nil {
-		return nil, newBackendError(err)
-	}
+		// Create a new application-specific password and set it in
+		// the database. We don't need to update the User object as
+		// we're not reusing it.
+		asp := &AppSpecificPasswordInfo{
+			ID:      randomAppSpecificPasswordID(),
+			Service: req.Service,
+			Comment: req.Notes,
+		}
+		password := randomAppSpecificPassword()
+		encPass := pwhash.Encrypt(password)
+		if err := tx.SetApplicationSpecificPassword(ctx, user, asp, encPass); err != nil {
+			return newBackendError(err)
+		}
 
-	// Create or update the user encryption key associated with
-	// this password. The user encryption key IDs for ASPs all
-	// have an 'asp_' prefix, followed by the ASP ID.
-	if user.HasEncryptionKeys {
-		keyID := "asp_" + asp.ID
-		if err := s.updateUserEncryptionKeys(ctx, user, req.CurPassword, password, keyID); err != nil {
-			return nil, err
+		// Create or update the user encryption key associated with
+		// this password. The user encryption key IDs for ASPs all
+		// have an 'asp_' prefix, followed by the ASP ID.
+		if user.HasEncryptionKeys {
+			keys, decrypted, err := s.readOrInitializeEncryptionKeys(ctx, tx, user, req.CurPassword, req.CurPassword)
+			if err != nil {
+				return err
+			}
+			keyID := "asp_" + asp.ID
+			keys, err = updateEncryptionKey(keys, decrypted, keyID, password)
+			if err != nil {
+				return err
+			}
+			if err := tx.SetUserEncryptionKeys(ctx, user, keys); err != nil {
+				return newBackendError(err)
+			}
 		}
-	}
 
-	return &CreateApplicationSpecificPasswordResponse{
-		Password: password,
-	}, nil
+		resp.Password = password
+		return nil
+	})
+	return &resp, err
 }
 
+// DeleteApplicationSpecificPasswordRequest is the request type for
+// AccountService.DeleteApplicationSpecificPassword().
 type DeleteApplicationSpecificPasswordRequest struct {
 	RequestBase
 	AspID string `json:"asp_id"`
@@ -420,82 +395,40 @@ type DeleteApplicationSpecificPasswordRequest struct {
 
 // DeleteApplicationSpecificPassword destroys an application-specific
 // password, identified by its unique ID.
-func (s *AccountService) DeleteApplicationSpecificPassword(ctx context.Context, req *DeleteApplicationSpecificPasswordRequest) error {
-	user, err := s.authorizeUser(ctx, req.Username, req.SSO)
-	if err != nil {
-		return err
-	}
-
-	if err = s.backend.DeleteApplicationSpecificPassword(ctx, user, req.AspID); err != nil {
-		return err
-	}
-
-	// Delete the user encryption key associated with this
-	// password (we're going to find it via its ID).
-	keys, err := s.backend.GetUserEncryptionKeys(ctx, user)
-	if err != nil {
-		return err
-	}
-	if len(keys) == 0 {
-		return nil
-	}
-	aspKeyID := "asp_" + req.AspID
-	var newKeys []*UserEncryptionKey
-	for _, k := range keys {
-		if k.ID != aspKeyID {
-			newKeys = append(newKeys, k)
+func (s *AccountService) DeleteApplicationSpecificPassword(ctx context.Context, tx TX, req *DeleteApplicationSpecificPasswordRequest) error {
+	return s.handleUserRequest(ctx, tx, req, s.authUser(req.RequestBase), func(ctx context.Context, user *User) error {
+		if err := tx.DeleteApplicationSpecificPassword(ctx, user, req.AspID); err != nil {
+			return err
 		}
-	}
-	return s.backend.SetUserEncryptionKeys(ctx, user, newKeys)
-}
-
-type ChangeResourcePasswordRequest struct {
-	RequestBase
-	ResourceID string `json:"resource_id"`
-	Password   string `json:"password"`
-}
-
-func (r *ChangeResourcePasswordRequest) Validate() error {
-	if r.Password == "" {
-		return errors.New("empty 'password' attribute")
-	}
-	return nil
-}
 
-// ChangeResourcePassword modifies the password associated with a
-// specific resource. Resources that do not support this method should
-// return an error from the backend.
-func (s *AccountService) ChangeResourcePassword(ctx context.Context, req *ChangeResourcePasswordRequest) error {
-	_, err := s.authorizeUser(ctx, req.Username, req.SSO)
-	if err != nil {
-		return err
-	}
-
-	if err = req.Validate(); err != nil {
-		return newRequestError(err)
-	}
-
-	r, err := s.backend.GetResource(ctx, req.Username, req.ResourceID)
-	if err != nil {
-		return newBackendError(err)
-	}
-	if r == nil {
-		return ErrResourceNotFound
-	}
-
-	encPass := pwhash.Encrypt(req.Password)
-	if err := s.backend.SetResourcePassword(ctx, req.Username, r, encPass); err != nil {
-		return newBackendError(err)
-	}
-	return nil
+		// Delete the user encryption key associated with this
+		// password (we're going to find it via its ID).
+		keys, err := tx.GetUserEncryptionKeys(ctx, user)
+		if err != nil {
+			return err
+		}
+		if len(keys) == 0 {
+			return nil
+		}
+		aspKeyID := "asp_" + req.AspID
+		var newKeys []*UserEncryptionKey
+		for _, k := range keys {
+			if k.ID != aspKeyID {
+				newKeys = append(newKeys, k)
+			}
+		}
+		return tx.SetUserEncryptionKeys(ctx, user, newKeys)
+	})
 }
 
+// MoveResourceRequest is the request type for AccountService.MoveResource().
 type MoveResourceRequest struct {
 	RequestBase
-	ResourceID string `json:"resource_id"`
-	Shard      string `json:"shard"`
+	ResourceID ResourceID `json:"resource_id"`
+	Shard      string     `json:"shard"`
 }
 
+// MoveResourceResponse is the response type for AccountService.MoveResource().
 type MoveResourceResponse struct {
 	MovedIDs []string `json:"moved_ids"`
 }
@@ -504,42 +437,41 @@ type MoveResourceResponse struct {
 // between shards. Resources that are part of a group are moved all at
 // once regardless of which individual ResourceID is provided as long
 // as it belongs to the group.
-func (s *AccountService) MoveResource(ctx context.Context, req *MoveResourceRequest) (*MoveResourceResponse, error) {
-	user, err := s.authorizeAdmin(ctx, req.Username, req.SSO)
-	if err != nil {
-		return nil, err
-	}
-
-	// Collect all related resources, as they should all be moved at once.
-	r, err := s.backend.GetResource(ctx, req.Username, req.ResourceID)
-	if err != nil {
-		return nil, err
-	}
-	var resources []*Resource
-	if r.Group != "" {
-		resources = append(resources, user.GetResourcesByGroup(r.Group)...)
-	} else {
-		resources = []*Resource{r}
-	}
-
+func (s *AccountService) MoveResource(ctx context.Context, tx TX, req *MoveResourceRequest) (*MoveResourceResponse, error) {
 	var resp MoveResourceResponse
-	for _, r := range resources {
-		r.Shard = req.Shard
-		if err := s.backend.UpdateResource(ctx, req.Username, r); err != nil {
-			return nil, err
+	err := s.handleUserRequest(ctx, tx, req, s.authAdmin(req.RequestBase), func(ctx context.Context, user *User) error {
+		// Collect all related resources, as they should all be moved at once.
+		r, err := tx.GetResource(ctx, req.ResourceID)
+		if err != nil {
+			return err
+		}
+		var resources []*Resource
+		if r.Group != "" {
+			resources = append(resources, user.GetResourcesByGroup(r.Group)...)
+		} else {
+			resources = []*Resource{r}
 		}
-		resp.MovedIDs = append(resp.MovedIDs, r.ID)
-	}
 
-	return &resp, nil
+		for _, r := range resources {
+			r.Shard = req.Shard
+			if err := tx.UpdateResource(ctx, r); err != nil {
+				return err
+			}
+			resp.MovedIDs = append(resp.MovedIDs, r.ID.String())
+		}
+		return nil
+	})
+	return &resp, err
 }
 
+// EnableOTPRequest is the request type for AccountService.EnableOTP().
 type EnableOTPRequest struct {
 	RequestBase
 	TOTPSecret string `json:"totp_secret"`
 }
 
-func (r *EnableOTPRequest) Validate() error {
+// Validate the request.
+func (r *EnableOTPRequest) Validate(_ context.Context, _ *AccountService) error {
 	// TODO: the length here is bogus, replace with real value.
 	if r.TOTPSecret != "" && len(r.TOTPSecret) != 32 {
 		return errors.New("bad totp_secret value")
@@ -547,6 +479,7 @@ func (r *EnableOTPRequest) Validate() error {
 	return nil
 }
 
+// EnableOTPResponse is the response type for AccountService.EnableOTP().
 type EnableOTPResponse struct {
 	TOTPSecret string `json:"totp_secret"`
 }
@@ -556,50 +489,268 @@ type EnableOTPResponse struct {
 // (useful for UX that confirms that the user is able to login first),
 // or it can let the server generate a new secret by passing an empty
 // totp_secret.
-func (s *AccountService) EnableOTP(ctx context.Context, req *EnableOTPRequest) (*EnableOTPResponse, error) {
-	user, err := s.authorizeUser(ctx, req.Username, req.SSO)
-	if err != nil {
-		return nil, err
+func (s *AccountService) EnableOTP(ctx context.Context, tx TX, req *EnableOTPRequest) (*EnableOTPResponse, error) {
+	var resp EnableOTPResponse
+	err := s.handleUserRequest(ctx, tx, req, s.authUser(req.RequestBase), func(ctx context.Context, user *User) (err error) {
+		// Replace or initialize the TOTP secret.
+		if req.TOTPSecret == "" {
+			req.TOTPSecret, err = generateTOTPSecret()
+			if err != nil {
+				return err
+			}
+		}
+
+		if err := tx.SetUserTOTPSecret(ctx, user, req.TOTPSecret); err != nil {
+			return newBackendError(err)
+		}
+
+		resp.TOTPSecret = req.TOTPSecret
+		return nil
+	})
+	return &resp, err
+}
+
+// DisableOTPRequest is the request type for AccountService.DisableOTP().
+type DisableOTPRequest struct {
+	RequestBase
+}
+
+// DisableOTP disables two-factor authentication for a user.
+func (s *AccountService) DisableOTP(ctx context.Context, tx TX, req *DisableOTPRequest) error {
+	return s.handleUserRequest(ctx, tx, req, s.authUser(req.RequestBase), func(ctx context.Context, user *User) error {
+		// Delete the TOTP secret (if present).
+		if err := tx.DeleteUserTOTPSecret(ctx, user); err != nil {
+			return newBackendError(err)
+		}
+		return nil
+	})
+}
+
+// AddEmailAliasRequest is the request type for AccountService.AddEmailAlias().
+type AddEmailAliasRequest struct {
+	ResourceRequestBase
+	Addr string `json:"addr"`
+}
+
+// Validate the request.
+func (r *AddEmailAliasRequest) Validate(ctx context.Context, s *AccountService) error {
+	if r.ResourceID.Type() != ResourceTypeEmail {
+		return errors.New("this operation only works on email resources")
 	}
+	return s.fieldValidators.email(ctx, r.Addr)
+}
+
+const maxEmailAliases = 5
+
+// AddEmailAlias adds an alias (additional address) to an email resource.
+func (s *AccountService) AddEmailAlias(ctx context.Context, tx TX, req *AddEmailAliasRequest) error {
+	return s.handleResourceRequest(ctx, tx, req, s.authResource(req.ResourceRequestBase), func(ctx context.Context, r *Resource) error {
+		// Allow at most 5 aliases.
+		if len(r.Email.Aliases) >= maxEmailAliases {
+			return errors.New("too many aliases")
+		}
+
+		r.Email.Aliases = append(r.Email.Aliases, req.Addr)
+		if err := tx.UpdateResource(ctx, r); err != nil {
+			return err
+		}
+
+		s.audit.Log(ctx, r.ID, fmt.Sprintf("added alias %s", req.Addr))
+		return nil
+	})
+}
+
+// DeleteEmailAliasRequest is the request type for AccountService.DeleteEmailAlias().
+type DeleteEmailAliasRequest struct {
+	ResourceRequestBase
+	Addr string `json:"addr"`
+}
 
-	if err = req.Validate(); err != nil {
-		return nil, newRequestError(err)
+// Validate the request.
+func (r *DeleteEmailAliasRequest) Validate(ctx context.Context, s *AccountService) error {
+	if r.ResourceID.Type() != ResourceTypeEmail {
+		return errors.New("this operation only works on email resources")
 	}
+	return nil
+}
 
-	// Replace or initialize the TOTP secret.
-	if req.TOTPSecret == "" {
-		req.TOTPSecret, err = generateTOTPSecret()
-		if err != nil {
-			return nil, err
+// DeleteEmailAlias removes an alias from an email resource.
+func (s *AccountService) DeleteEmailAlias(ctx context.Context, tx TX, req *DeleteEmailAliasRequest) error {
+	return s.handleResourceRequest(ctx, tx, req, s.authResource(req.ResourceRequestBase), func(ctx context.Context, r *Resource) error {
+		var aliases []string
+		for _, a := range r.Email.Aliases {
+			if a != req.Addr {
+				aliases = append(aliases, a)
+			}
+		}
+		r.Email.Aliases = aliases
+		if err := tx.UpdateResource(ctx, r); err != nil {
+			return err
+		}
+
+		s.audit.Log(ctx, r.ID, fmt.Sprintf("removed alias %s", req.Addr))
+		return nil
+	})
+}
+
+// CreateResourcesRequest is the request type for AccountService.CreateResources().
+type CreateResourcesRequest struct {
+	SSO       string      `json:"sso"`
+	Resources []*Resource `json:"resources"`
+}
+
+// CreateResourcesResponse is the response type for AccountService.CreateResources().
+type CreateResourcesResponse struct {
+	// Resources to create. All must either be global resources
+	// (no user ownership), or belong to the same user.
+	Resources []*Resource `json:"resources"`
+}
+
+// ApplyTemplate fills in default values for the resources in the request.
+func (req *CreateResourcesRequest) ApplyTemplate(ctx context.Context, s *AccountService, user *User) {
+	for _, r := range req.Resources {
+		s.resourceTemplates.applyTemplate(ctx, r, user)
+	}
+}
+
+// ValidationUser returns the user to be used for validation purposes.
+func (req *CreateResourcesRequest) ValidationUser(ctx context.Context, tx TX) *User {
+	// Fetch the user associated with the first resource (if
+	// any). Since resource validation might reference other
+	// resources, we need to provide it with a view of what the
+	// future resources will be. So we merge the resources from
+	// the database with those from the request, using a local
+	// copy of the User object.
+	if len(req.Resources) > 0 {
+		if username := req.Resources[0].ID.User(); username != "" {
+			u, err := getUserOrDie(ctx, tx, username)
+			if err != nil {
+				return nil
+			}
+			user := *u
+			user.Resources = mergeResources(u.Resources, req.Resources)
+			return &user
+		}
+	}
+	return nil
+}
+
+// Validate the request.
+func (req *CreateResourcesRequest) Validate(ctx context.Context, s *AccountService, user *User) error {
+	var owner string
+	if user != nil {
+		owner = user.Name
+	}
+	for _, r := range req.Resources {
+		// Check same-user ownership.
+		if r.ID.User() != owner {
+			return errors.New("resources owned by different users")
+		}
+
+		// Validate the resource.
+		if err := s.resourceValidator.validateResource(ctx, r, user); err != nil {
+			log.Printf("validation error while creating resource %+v: %v", r, err)
+			return err
+		}
+	}
+	return nil
+}
+
+// CreateResources can create one or more resources.
+func (s *AccountService) CreateResources(ctx context.Context, tx TX, req *CreateResourcesRequest) (*CreateResourcesResponse, error) {
+	var resp CreateResourcesResponse
+	err := s.handleAdminRequest(ctx, tx, req, req.SSO, func(ctx context.Context) error {
+		for _, r := range req.Resources {
+			if err := tx.CreateResource(ctx, r); err != nil {
+				return err
+			}
+			resp.Resources = append(resp.Resources, r)
+		}
+		return nil
+	})
+	return &resp, err
+}
+
+// Merge two resource lists by ID (the second one wins), return a new list.
+func mergeResources(a, b []*Resource) []*Resource {
+	tmp := make(map[string]*Resource)
+	for _, l := range [][]*Resource{a, b} {
+		for _, r := range l {
+			tmp[r.ID.String()] = r
 		}
 	}
-	if err := s.backend.SetUserTOTPSecret(ctx, user, req.TOTPSecret); err != nil {
-		return nil, newBackendError(err)
+	out := make([]*Resource, 0, len(tmp))
+	for _, r := range tmp {
+		out = append(out, r)
 	}
+	return out
+}
 
-	return &EnableOTPResponse{
-		TOTPSecret: req.TOTPSecret,
-	}, nil
+// CreateUserRequest is the request type for AccountService.CreateUser().
+type CreateUserRequest struct {
+	SSO  string `json:"sso"`
+	User *User  `json:"user"`
 }
 
-type DisableOTPRequest struct {
-	RequestBase
+// ApplyTemplate fills in default values for the resources in the request.
+func (req *CreateUserRequest) ApplyTemplate(ctx context.Context, s *AccountService, user *User) {
+	for _, r := range req.User.Resources {
+		s.resourceTemplates.applyTemplate(ctx, r, user)
+	}
 }
 
-// DisableOTP disables two-factor authentication for a user.
-func (s *AccountService) DisableOTP(ctx context.Context, req *DisableOTPRequest) error {
-	user, err := s.authorizeUser(ctx, req.Username, req.SSO)
-	if err != nil {
+// Validate the request.
+func (req *CreateUserRequest) Validate(ctx context.Context, s *AccountService, _ *User) error {
+	// Override server-generated values.
+	fillUserTemplate(req.User)
+
+	// Validate the user *and* all resources.
+	if err := s.userValidator(ctx, req.User); err != nil {
+		log.Printf("validation error while creating user %+v: %v", req.User, err)
 		return err
 	}
-
-	// Delete the TOTP secret (if present).
-	if err := s.backend.DeleteUserTOTPSecret(ctx, user); err != nil {
-		return newBackendError(err)
+	for _, r := range req.User.Resources {
+		if err := s.resourceValidator.validateResource(ctx, r, req.User); err != nil {
+			log.Printf("validation error while creating resource %+v: %v", r, err)
+			return err
+		}
 	}
+
 	return nil
 }
 
+// CreateUserResponse is the request type for AccountService.CreateUser().
+type CreateUserResponse struct {
+	User *User `json:"user,omitempty"`
+}
+
+// Make sure that only user-settable fields are set in the User in a
+// CreateUserRequest.
+func fillUserTemplate(user *User) {
+	// Some fields should be unset because there are specific
+	// methods to modify those attributes.
+	user.Has2FA = false
+	user.HasEncryptionKeys = false
+	user.PasswordRecoveryHint = ""
+	user.AppSpecificPasswords = nil
+	if user.Lang == "" {
+		user.Lang = "en"
+	}
+}
+
+// CreateUser creates a new user along with the associated resources.
+func (s *AccountService) CreateUser(ctx context.Context, tx TX, req *CreateUserRequest) (*CreateUserResponse, error) {
+	var resp CreateUserResponse
+	err := s.handleAdminRequest(ctx, tx, req, req.SSO, func(ctx context.Context) (err error) {
+		if err := tx.CreateUser(ctx, req.User); err != nil {
+			return err
+		}
+		resp.User = req.User
+		return nil
+	})
+	return &resp, err
+}
+
 const appSpecificPasswordLen = 64
 
 func randomBase64(n int) string {
diff --git a/actions_test.go b/actions_test.go
index 4c9443da21cb1657efb952163b96cb27442dc657..ad0a6fa74ba298e9f8d779cec9976d30fa30c392 100644
--- a/actions_test.go
+++ b/actions_test.go
@@ -2,6 +2,7 @@ package accountserver
 
 import (
 	"context"
+	"errors"
 	"testing"
 
 	sso "git.autistici.org/id/go-sso"
@@ -15,15 +16,38 @@ 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
 }
 
-func (b *fakeBackend) GetResource(_ context.Context, username, resourceID string) (*Resource, error) {
-	return b.resources[username][resourceID], nil
+func (b *fakeBackend) CreateUser(_ context.Context, user *User) error {
+	b.users[user.Name] = user
+	return nil
+}
+
+func (b *fakeBackend) GetResource(_ context.Context, resourceID ResourceID) (*Resource, error) {
+	return b.resources[resourceID.User()][resourceID.String()], nil
 }
 
-func (b *fakeBackend) UpdateResource(_ context.Context, username string, r *Resource) error {
+func (b *fakeBackend) UpdateResource(_ context.Context, r *Resource) error {
+	b.resources[r.ID.User()][r.ID.String()] = r
+	return nil
+}
+
+func (b *fakeBackend) CreateResource(_ context.Context, r *Resource) error {
+	if _, ok := b.resources[r.ID.User()][r.ID.String()]; ok {
+		return errors.New("resource already exists")
+	}
+
+	b.resources[r.ID.User()][r.ID.String()] = r
 	return nil
 }
 
@@ -32,7 +56,11 @@ func (b *fakeBackend) SetUserPassword(_ context.Context, user *User, password st
 	return nil
 }
 
-func (b *fakeBackend) SetResourcePassword(_ context.Context, username string, r *Resource, password string) error {
+func (b *fakeBackend) SetPasswordRecoveryHint(_ context.Context, user *User, hint, response string) error {
+	return nil
+}
+
+func (b *fakeBackend) SetResourcePassword(_ context.Context, r *Resource, password string) error {
 	return nil
 }
 
@@ -42,6 +70,7 @@ func (b *fakeBackend) GetUserEncryptionKeys(_ context.Context, user *User) ([]*U
 
 func (b *fakeBackend) SetUserEncryptionKeys(_ context.Context, user *User, keys []*UserEncryptionKey) error {
 	b.encryptionKeys[user.Name] = keys
+	b.users[user.Name].HasEncryptionKeys = true
 	return nil
 }
 
@@ -66,7 +95,16 @@ 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) {
+	for _, fr := range rsrcs {
+		for _, ur := range b.resources {
+			for _, r := range ur {
+				if r.ID.Type() == fr.Type && r.ID.Name() == fr.Name {
+					return true, nil
+				}
+			}
+		}
+	}
 	return false, nil
 }
 
@@ -76,7 +114,7 @@ type fakeValidator struct {
 	adminUser string
 }
 
-func (v *fakeValidator) Validate(tkt string, nonce string, service string, _ []string) (*sso.Ticket, error) {
+func (v *fakeValidator) Validate(tkt, nonce, service string, _ []string) (*sso.Ticket, error) {
 	// The sso ticket username is just the ticket itself.
 	var groups []string
 	if tkt == v.adminUser {
@@ -90,40 +128,79 @@ func (v *fakeValidator) Validate(tkt string, nonce string, service string, _ []s
 	}, nil
 }
 
+func (b *fakeBackend) addUser(user *User) {
+	b.users[user.Name] = user
+	b.resources[user.Name] = make(map[string]*Resource)
+	for _, r := range user.Resources {
+		b.resources[user.Name][r.ID.String()] = r
+	}
+}
+
 func createFakeBackend() *fakeBackend {
 	fb := &fakeBackend{
-		users: map[string]*User{
-			"testuser": &User{
-				Name: "testuser",
-				Resources: []*Resource{
-					{
-						ID:     "email/testuser@example.com",
-						Name:   "testuser@example.com",
-						Type:   ResourceTypeEmail,
-						Status: ResourceStatusActive,
-						Email:  &Email{},
-					},
-				},
-			},
+		users: make(map[string]*User),
+		resources: map[string]map[string]*Resource{
+			// For global (user-less) resources, where CreateUser is not called.
+			"": make(map[string]*Resource),
 		},
-		resources:            make(map[string]map[string]*Resource),
 		passwords:            make(map[string]string),
 		appSpecificPasswords: make(map[string][]*AppSpecificPasswordInfo),
 		encryptionKeys:       make(map[string][]*UserEncryptionKey),
 	}
+	fb.addUser(&User{
+		Name: "testuser",
+		Resources: []*Resource{
+			{
+				ID:     NewResourceID(ResourceTypeEmail, "testuser", "testuser@example.com"),
+				Name:   "testuser@example.com",
+				Status: ResourceStatusActive,
+				Email: &Email{
+					Maildir: "example.com/testuser",
+				},
+			},
+			{
+				ID:     NewResourceID(ResourceTypeDAV, "testuser", "dav1"),
+				Name:   "dav1",
+				Status: ResourceStatusActive,
+				DAV: &WebDAV{
+					Homedir: "/home/dav1",
+				},
+			},
+		},
+	})
 	return fb
 }
 
 func testConfig() *Config {
 	var c Config
+	c.ForbiddenUsernames = []string{"root"}
+	c.AvailableDomains = map[string][]string{
+		ResourceTypeEmail:       []string{"example.com"},
+		ResourceTypeMailingList: []string{"example.com"},
+	}
 	c.SSO.Domain = "mydomain"
 	c.SSO.Service = "service/"
 	c.SSO.AdminGroup = testAdminGroupName
+	c.Shards.Available = map[string][]string{
+		ResourceTypeEmail:       []string{"host1", "host2", "host3"},
+		ResourceTypeMailingList: []string{"host1", "host2", "host3"},
+		ResourceTypeWebsite:     []string{"host1", "host2", "host3"},
+		ResourceTypeDomain:      []string{"host1", "host2", "host3"},
+		ResourceTypeDAV:         []string{"host1", "host2", "host3"},
+	}
+	c.Shards.Allowed = c.Shards.Available
 	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 +208,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 +218,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 +234,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,21 +249,38 @@ func TestService_Auth(t *testing.T) {
 
 func TestService_ChangePassword(t *testing.T) {
 	fb := createFakeBackend()
-	svc := newAccountServiceWithSSO(fb, testConfig(), &fakeValidator{})
+	tx, _ := fb.NewTransaction()
+	svc, _ := newAccountServiceWithSSO(fb, testConfig(), &fakeValidator{})
 
-	req := &ChangeUserPasswordRequest{
-		PrivilegedRequestBase: PrivilegedRequestBase{
-			RequestBase: RequestBase{
-				Username: "testuser",
-				SSO:      "testuser",
-			},
-			CurPassword: "cur",
-		},
-		Password: "password",
+	testdata := []struct {
+		password    string
+		newPassword string
+		expectedOk  bool
+	}{
+		// Ordering is important as it is meant to emulate
+		// setting the password, failing to reset it, then
+		// succeeding.
+		{"password", "new_password", true},
+		{"BADPASS", "new_password_2", false},
+		{"new_password", "new_password_2", true},
 	}
-	err := svc.ChangeUserPassword(context.TODO(), req)
-	if err != nil {
-		t.Fatal(err)
+	for _, td := range testdata {
+		req := &ChangeUserPasswordRequest{
+			PrivilegedRequestBase: PrivilegedRequestBase{
+				RequestBase: RequestBase{
+					Username: "testuser",
+					SSO:      "testuser",
+				},
+				CurPassword: td.password,
+			},
+			Password: td.newPassword,
+		}
+		err := svc.ChangeUserPassword(context.TODO(), tx, req)
+		if err == nil && !td.expectedOk {
+			t.Fatalf("ChangeUserPassword(old=%s new=%s) should have failed but didn't", td.password, td.newPassword)
+		} else if err != nil && td.expectedOk {
+			t.Fatalf("ChangeUserPassword(old=%s new=%s) failed: %v", td.password, td.newPassword, err)
+		}
 	}
 
 	if _, ok := fb.passwords["testuser"]; !ok {
@@ -196,3 +290,186 @@ func TestService_ChangePassword(t *testing.T) {
 		t.Errorf("no encryption keys were set")
 	}
 }
+
+// Lower level test that basically corresponds to the same operations
+// as TestService_ChangePassword above, but exercises the
+// initializeUserEncryptionKeys / updateUserEncryptionKeys code path
+// directly.
+func TestService_EncryptionKeys(t *testing.T) {
+	fb := createFakeBackend()
+	svc, _ := newAccountServiceWithSSO(fb, testConfig(), &fakeValidator{})
+	tx, _ := fb.NewTransaction()
+	ctx := context.Background()
+
+	user, _ := getUserOrDie(ctx, tx, "testuser")
+
+	// Set the keys to something.
+	keys, _, err := svc.initializeEncryptionKeys(ctx, tx, user, "password")
+	if err != nil {
+		t.Fatal("init", err)
+	}
+	if err := tx.SetUserEncryptionKeys(ctx, user, keys); err != nil {
+		t.Fatal("SetUserEncryptionKeys", err)
+	}
+	if n := len(fb.encryptionKeys["testuser"]); n != 1 {
+		t.Fatalf("found %d encryption keys, expected 1", n)
+	}
+
+	// Try to read (decrypt) them again using bad / good passwords.
+	if _, _, err := svc.readOrInitializeEncryptionKeys(ctx, tx, user, "BADPASS", "new_password"); err == nil {
+		t.Fatal("read with bad password did not fail")
+	}
+	if _, _, err := svc.readOrInitializeEncryptionKeys(ctx, tx, user, "password", "new_password"); err != nil {
+		t.Fatal("readOrInitialize", err)
+	}
+}
+
+// Try adding aliases to the email resource.
+func TestService_AddEmailAlias(t *testing.T) {
+	svc, tx := testService("")
+
+	testdata := []struct {
+		addr       string
+		expectedOk bool
+	}{
+		{"alias@example.com", true},
+		{"another-example-address@example.com", true},
+		{"root@example.com", false},
+		{"alias@other-domain.com", false},
+	}
+	for _, td := range testdata {
+		req := &AddEmailAliasRequest{
+			ResourceRequestBase: ResourceRequestBase{
+				SSO:        "testuser",
+				ResourceID: NewResourceID(ResourceTypeEmail, "testuser", "testuser@example.com"),
+			},
+			Addr: td.addr,
+		}
+		err := svc.AddEmailAlias(context.TODO(), tx, req)
+		if err != nil && td.expectedOk {
+			t.Errorf("AddEmailAlias(%s) failed: %v", td.addr, err)
+		} else if err == nil && !td.expectedOk {
+			t.Errorf("AddEmailAlias(%s) did not fail but should have", td.addr)
+		}
+	}
+}
+
+func TestService_CreateResource(t *testing.T) {
+	svc, tx := testService("admin")
+
+	req := &CreateResourcesRequest{
+		SSO: "admin",
+		Resources: []*Resource{
+			&Resource{
+				ID:            NewResourceID(ResourceTypeDAV, "testuser", "dav2"),
+				Name:          "dav2",
+				Status:        ResourceStatusActive,
+				Shard:         "host2",
+				OriginalShard: "host2",
+				DAV: &WebDAV{
+					Homedir: "/home/dav2",
+				},
+			},
+		},
+	}
+
+	// The request should succeed the first time around.
+	_, err := svc.CreateResources(context.Background(), tx, req)
+	if err != nil {
+		t.Fatal("CreateResources", err)
+	}
+
+	// The object already exists, so the same request should fail now.
+	_, err = svc.CreateResources(context.Background(), tx, req)
+	if err == nil {
+		t.Fatal("creating a duplicate resource did not fail")
+	}
+}
+
+func TestService_CreateResource_List(t *testing.T) {
+	svc, tx := testService("admin")
+
+	// A list is an example of a user-less (global) resource.
+	req := &CreateResourcesRequest{
+		SSO: "admin",
+		Resources: []*Resource{
+			&Resource{
+				ID:            NewResourceID(ResourceTypeMailingList, "list@example.com"),
+				Name:          "list@example.com",
+				Status:        ResourceStatusActive,
+				Shard:         "host2",
+				OriginalShard: "host2",
+				List: &MailingList{
+					Admins: []string{"testuser@example.com"},
+				},
+			},
+		},
+	}
+
+	// The request should succeed.
+	_, err := svc.CreateResources(context.Background(), tx, req)
+	if err != nil {
+		t.Fatal("CreateResources", err)
+	}
+}
+
+func TestService_CreateUser(t *testing.T) {
+	svc, tx := testService("admin")
+
+	req := &CreateUserRequest{
+		SSO: "admin",
+		User: &User{
+			Name: "testuser2@example.com",
+			Resources: []*Resource{
+				&Resource{
+					ID:            NewResourceID(ResourceTypeEmail, "testuser2@example.com", "testuser2@example.com"),
+					Name:          "testuser2@example.com",
+					Status:        ResourceStatusActive,
+					Shard:         "host2",
+					OriginalShard: "host2",
+					Email: &Email{
+						Maildir: "example.com/testuser2",
+					},
+				},
+			},
+		},
+	}
+
+	// The request should succeed the first time around.
+	resp, err := svc.CreateUser(context.Background(), tx, req)
+	if err != nil {
+		t.Fatal("CreateResources", err)
+	}
+	if resp.User.Name != "testuser2@example.com" {
+		t.Fatalf("unexpected user in response: got %s, expected testuser2", resp.User.Name)
+	}
+}
+
+func TestService_CreateUser_FailIfNotAdmin(t *testing.T) {
+	svc, tx := testService("admin")
+
+	req := &CreateUserRequest{
+		SSO: "testuser",
+		User: &User{
+			Name: "testuser2@example.com",
+			Resources: []*Resource{
+				&Resource{
+					ID:            NewResourceID(ResourceTypeEmail, "testuser2@example.com", "testuser2@example.com"),
+					Name:          "testuser2@example.com",
+					Status:        ResourceStatusActive,
+					Shard:         "host2",
+					OriginalShard: "host2",
+					Email: &Email{
+						Maildir: "example.com/testuser2",
+					},
+				},
+			},
+		},
+	}
+
+	// The request should succeed the first time around.
+	_, err := svc.CreateUser(context.Background(), tx, req)
+	if err == nil {
+		t.Fatal("CreateResources did not fail")
+	}
+}
diff --git a/audit.go b/audit.go
new file mode 100644
index 0000000000000000000000000000000000000000..0e339c7def0841e6c08a166506ce84ccdb7e6cf8
--- /dev/null
+++ b/audit.go
@@ -0,0 +1,39 @@
+package accountserver
+
+import (
+	"context"
+	"encoding/json"
+	"log"
+)
+
+type auditLogger interface {
+	Log(context.Context, ResourceID, string)
+}
+
+type auditLogEntry struct {
+	User         string `json:"user,omitempty"`
+	By           string `json:"by"`
+	Message      string `json:"message"`
+	Comment      string `json:"comment,omitempty"`
+	ResourceName string `json:"resource_name,omitempty"`
+	ResourceType string `json:"resource_type,omitempty"`
+}
+
+type syslogAuditLogger struct{}
+
+func (l *syslogAuditLogger) Log(ctx context.Context, resourceID ResourceID, what string) {
+	e := auditLogEntry{
+		User:    userFromContext(ctx),
+		By:      authUserFromContext(ctx),
+		Message: what,
+		Comment: commentFromContext(ctx),
+	}
+
+	if !resourceID.Empty() {
+		e.ResourceName = resourceID.Name()
+		e.ResourceType = resourceID.Type()
+	}
+
+	data, _ := json.Marshal(&e)
+	log.Printf("@cee:%s", data)
+}
diff --git a/backend/composite_values.go b/backend/composite_values.go
index e6287f54d3bca02e9309a0165221210ddaa151c0..c6c57006c0bd5714e8a7f1da6f09c9c331fbe7f5 100644
--- a/backend/composite_values.go
+++ b/backend/composite_values.go
@@ -2,11 +2,14 @@ package backend
 
 import (
 	"errors"
+	"fmt"
 	"strings"
 
 	"git.autistici.org/ai3/accountserver"
 )
 
+// Extend the AppSpecificPasswordInfo type, which only contains public
+// information, with the encrypted password.
 type appSpecificPassword struct {
 	accountserver.AppSpecificPasswordInfo
 	Password string
@@ -63,3 +66,26 @@ func getASPInfo(asps []*appSpecificPassword) []*accountserver.AppSpecificPasswor
 	}
 	return out
 }
+
+func decodeUserEncryptionKeys(values []string) []*accountserver.UserEncryptionKey {
+	var out []*accountserver.UserEncryptionKey
+	for _, value := range values {
+		idx := strings.IndexByte(value, ':')
+		if idx < 0 {
+			continue
+		}
+		out = append(out, &accountserver.UserEncryptionKey{
+			ID:  value[:idx],
+			Key: []byte(value[idx+1:]),
+		})
+	}
+	return out
+}
+
+func encodeUserEncryptionKeys(keys []*accountserver.UserEncryptionKey) []string {
+	var out []string
+	for _, key := range keys {
+		out = append(out, fmt.Sprintf("%s:%s", key.ID, string(key.Key)))
+	}
+	return out
+}
diff --git a/backend/diff.go b/backend/diff.go
deleted file mode 100644
index b0c3d00ccb9e757c305e87238ff44fa728f19d19..0000000000000000000000000000000000000000
--- 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/ldap_server_test.go b/backend/ldap_server_test.go
deleted file mode 100644
index 8f1ba78221ed2200e27b84e44dd393c1bae5476c..0000000000000000000000000000000000000000
--- a/backend/ldap_server_test.go
+++ /dev/null
@@ -1,42 +0,0 @@
-package backend
-
-import (
-	"fmt"
-	"io/ioutil"
-	"os/exec"
-	"testing"
-	"time"
-)
-
-type testLDAPServerConfig struct {
-	Base  string
-	Port  int
-	LDIFs []string
-}
-
-func startTestLDAPServer(t testing.TB, config *testLDAPServerConfig) func() {
-	tmpf, err := ioutil.TempFile("", "")
-	if err != nil {
-		t.Fatal(err)
-	}
-	fmt.Fprintf(tmpf, `ldap.rootDn=%s
-ldap.managerDn=cn=manager,%s
-ldap.managerPassword=password
-ldap.port=%d
-`, config.Base, config.Base, config.Port)
-	defer tmpf.Close()
-
-	args := []string{tmpf.Name()}
-	args = append(args, config.LDIFs...)
-	proc := exec.Command("./unboundid-ldap-server/bin/unboundid-ldap-server", args...)
-
-	if err := proc.Start(); err != nil {
-		t.Fatalf("error starting LDAP server: %v", err)
-	}
-
-	time.Sleep(1 * time.Second)
-
-	return func() {
-		proc.Process.Kill()
-	}
-}
diff --git a/backend/model.go b/backend/model.go
index d876ad2352271c30132f4788f022b16066c67392..5f110243150b01b1e8f555a40b8c068063d6eb92 100644
--- a/backend/model.go
+++ b/backend/model.go
@@ -2,9 +2,6 @@ package backend
 
 import (
 	"context"
-	"errors"
-	"fmt"
-	"os"
 	"strings"
 
 	ldaputil "git.autistici.org/ai3/go-common/ldap"
@@ -13,41 +10,70 @@ import (
 	"git.autistici.org/ai3/accountserver"
 )
 
-// Generic interface to LDAP - allows us to stub out the LDAP client while
-// 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()
-}
+const (
+	// Names of some well-known LDAP attributes.
+	totpSecretLDAPAttr        = "totpSecret"
+	preferredLanguageLDAPAttr = "preferredLanguage"
+	recoveryHintLDAPAttr      = "recoverQuestion"
+	recoveryResponseLDAPAttr  = "recoverAnswer"
+	aspLDAPAttr               = "appSpecificPassword"
+	storagePublicKeyLDAPAttr  = "storagePublicKey"
+	storagePrivateKeyLDAPAttr = "storageEncryptedSecretKey"
+	passwordLDAPAttr          = "userPassword"
+)
 
-// LDAPBackend is the interface to an LDAP-backed user database.
+// backend is the interface to an LDAP-backed user database.
 //
 // We keep a set of LDAP queries for each resource type, each having a
 // "resource" query to return a specific resource belonging to a user,
 // and a "presence" query that checks for existence of a resource for
 // all users.
-type LDAPBackend struct {
+type backend struct {
 	conn                ldapConn
+	baseDN              string
 	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
 }
 
 const ldapPoolSize = 20
 
+func (b *backend) NewTransaction() (accountserver.TX, error) {
+	return &backendTX{
+		ldapTX:  newLDAPTX(b.conn),
+		backend: b,
+	}, nil
+}
+
 // NewLDAPBackend initializes an LDAPBackend object with the given LDAP
 // connection pool.
-func NewLDAPBackend(uri, bindDN, bindPw, base string) (*LDAPBackend, error) {
+func NewLDAPBackend(uri, bindDN, bindPw, base string) (accountserver.Backend, error) {
 	pool, err := ldaputil.NewConnectionPool(uri, bindDN, bindPw, ldapPoolSize)
 	if err != nil {
 		return nil, err
 	}
+	return newLDAPBackendWithConn(pool, base)
+}
+
+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 &LDAPBackend{
-		conn: pool,
+	return &backend{
+		conn:   conn,
+		baseDN: base,
 		userQuery: mustCompileQueryConfig(&queryConfig{
 			Base:  "uid=${user},ou=People," + base,
 			Scope: "base",
@@ -65,333 +91,71 @@ func NewLDAPBackend(uri, bindDN, bindPw, base string) (*LDAPBackend, 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
 }
 
-func replaceVars(s string, vars map[string]string) string {
-	return os.Expand(s, func(k string) string {
-		return ldap.EscapeFilter(vars[k])
-	})
-}
-
-// queryConfig holds the parameters for a single LDAP query.
-type queryConfig struct {
-	Base        string
-	Filter      string
-	Scope       string
-	parsedScope int
-}
-
-func (q *queryConfig) validate() error {
-	if q.Base == "" {
-		return errors.New("empty search base")
-	}
-	// An empty filter is equivalent to objectClass=*.
-	if q.Filter == "" {
-		q.Filter = "(objectClass=*)"
-	}
-	q.parsedScope = ldap.ScopeWholeSubtree
-	if q.Scope != "" {
-		s, err := ldaputil.ParseScope(q.Scope)
-		if err != nil {
-			return err
-		}
-		q.parsedScope = s
-	}
-	return nil
-}
-
-func (q *queryConfig) searchRequest(vars map[string]string, attrs []string) *ldap.SearchRequest {
-	return ldap.NewSearchRequest(
-		replaceVars(q.Base, vars),
-		q.parsedScope,
-		ldap.NeverDerefAliases,
-		0,
-		0,
-		false,
-		replaceVars(q.Filter, vars),
-		attrs,
-		nil,
-	)
-}
-
-func mustCompileQueryConfig(q *queryConfig) *queryConfig {
-	if err := q.validate(); err != nil {
-		panic(err)
-	}
-	return q
-}
-
-func s2b(s string) bool {
-	switch s {
-	case "yes", "y", "on", "enabled", "true":
-		return true
-	default:
-		return false
-	}
-}
-
-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{
-		ID:            fmt.Sprintf("%s/%s", resourceType, name),
-		Name:          name,
-		Type:          resourceType,
-		Status:        entry.GetAttributeValue("status"),
-		Shard:         entry.GetAttributeValue("host"),
-		OriginalShard: entry.GetAttributeValue("originalHost"),
+func newUser(entry *ldap.Entry) (*accountserver.User, error) {
+	user := &accountserver.User{
+		Name:   entry.GetAttributeValue("uid"),
+		Lang:   entry.GetAttributeValue(preferredLanguageLDAPAttr),
+		Has2FA: (entry.GetAttributeValue(totpSecretLDAPAttr) != ""),
+		//HasEncryptionKeys: (len(entry.GetAttributeValues("storageEncryptionKey")) > 0),
+		//PasswordRecoveryHint: entry.GetAttributeValue("recoverQuestion"),
 	}
-}
-
-// 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
+	if user.Lang == "" {
+		user.Lang = "en"
 	}
-	return []string{s}
+	return user, nil
 }
 
-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)
-	}
-
+func userToLDAP(user *accountserver.User) (attrs []ldap.PartialAttribute) {
+	// Most attributes are read-only and have specialized methods to set them.
 	attrs = append(attrs, []ldap.PartialAttribute{
-		{Type: "status", Vals: s2l(r.Status)},
-		{Type: "host", Vals: s2l(r.Shard)},
-		{Type: "originalHost", Vals: s2l(r.OriginalShard)},
+		{Type: "objectClass", Vals: []string{"top", "person", "posixAccount", "shadowAccount", "organizationalPerson", "inetOrgPerson", "totpAccount"}},
+		{Type: "uid", Vals: s2l(user.Name)},
+		{Type: "cn", Vals: s2l(user.Name)},
+		{Type: "givenName", Vals: []string{"Private"}},
+		{Type: "sn", Vals: []string{"Private"}},
+		{Type: "gecos", Vals: s2l(user.Name)},
+		{Type: "loginShell", Vals: []string{"/bin/false"}},
+		{Type: "homeDirectory", Vals: []string{"/var/empty"}},
+		{Type: "shadowLastChange", Vals: []string{"12345"}},
+		{Type: "shadowWarning", Vals: []string{"7"}},
+		{Type: "shadowMax", Vals: []string{"99999"}},
+		{Type: preferredLanguageLDAPAttr, Vals: s2l(user.Lang)},
 	}...)
-
-	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
+	return
 }
 
-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 (tx *backendTX) getUserDN(user *accountserver.User) string {
+	return joinDN("uid="+user.Name, "ou=People", tx.backend.baseDN)
 }
 
-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
-}
+// CreateUser creates a new user.
+func (tx *backendTX) CreateUser(ctx context.Context, user *accountserver.User) error {
+	dn := tx.getUserDN(user)
 
-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)},
+	tx.create(dn)
+	for _, attr := range userToLDAP(user) {
+		tx.setAttr(dn, attr.Type, attr.Vals...)
 	}
-}
 
-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,
+	// Create all resources.
+	for _, r := range user.Resources {
+		if err := tx.CreateResource(ctx, r); err != nil {
+			return err
 		}
 	}
-	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 = fmt.Sprintf("%s/%s", accountserver.ResourceTypeWebsite, 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)},
-	}
-}
-
-type ldapUserData struct {
-	dn string
-}
-
-func newUser(entry *ldap.Entry) (*accountserver.User, error) {
-	user := &accountserver.User{
-		Name:              entry.GetAttributeValue("uid"),
-		Lang:              entry.GetAttributeValue("preferredLanguage"),
-		Has2FA:            (entry.GetAttributeValue("totpSecret") != ""),
-		HasEncryptionKeys: (len(entry.GetAttributeValues("storageEncryptionKey")) > 0),
-		//PasswordRecoveryHint: entry.GetAttributeValue("recoverQuestion"),
-	}
-	if user.Lang == "" {
-		user.Lang = "en"
-	}
-	user.Opaque = &ldapUserData{dn: entry.DN}
-	return user, nil
-}
-
-func getUserDN(user *accountserver.User) string {
-	lu, ok := user.Opaque.(*ldapUserData)
-	if !ok {
-		panic("no ldap user data")
-	}
-	return lu.dn
+	return nil
 }
 
 // GetUser returns a user.
-func (b *LDAPBackend) GetUser(ctx context.Context, username string) (*accountserver.User, error) {
+func (tx *backendTX) GetUser(ctx context.Context, username string) (*accountserver.User, error) {
 	// First of all, find the main user object, and just that one.
 	vars := map[string]string{"user": username}
-	result, err := b.conn.Search(ctx, b.userQuery.searchRequest(vars, nil))
+	result, err := tx.search(ctx, tx.backend.userQuery.searchRequest(vars, nil))
 	if err != nil {
 		if ldap.IsErrorWithCode(err, ldap.LDAPResultNoSuchObject) {
 			return nil, nil
@@ -408,8 +172,8 @@ func (b *LDAPBackend) GetUser(ctx context.Context, username string) (*accountser
 	// object we just created.
 	// TODO: parallelize.
 	// TODO: add support for non-LDAP resource queries.
-	for _, query := range b.userResourceQueries {
-		result, err = b.conn.Search(ctx, query.searchRequest(vars, nil))
+	for _, query := range tx.backend.userResourceQueries {
+		result, err = tx.search(ctx, query.searchRequest(vars, nil))
 		if err != nil {
 			continue
 		}
@@ -419,11 +183,13 @@ func (b *LDAPBackend) GetUser(ctx context.Context, username string) (*accountser
 			// object, a shortcoming of the legacy A/I database model. Set
 			// them on the main User object.
 			if isObjectClass(entry, "virtualMailUser") {
-				user.PasswordRecoveryHint = entry.GetAttributeValue("recoverQuestion")
-				user.AppSpecificPasswords = getASPInfo(decodeAppSpecificPasswords(entry.GetAttributeValues("appSpecificPassword")))
+				user.PasswordRecoveryHint = entry.GetAttributeValue(recoveryHintLDAPAttr)
+				user.AppSpecificPasswords = getASPInfo(decodeAppSpecificPasswords(entry.GetAttributeValues(aspLDAPAttr)))
+				user.HasEncryptionKeys = (entry.GetAttributeValue(storagePublicKeyLDAPAttr) != "")
 			}
+
 			// 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)
 			}
 		}
@@ -434,162 +200,113 @@ func (b *LDAPBackend) GetUser(ctx context.Context, username string) (*accountser
 	return user, nil
 }
 
-func singleAttributeQuery(dn, attribute string) *ldap.SearchRequest {
-	return ldap.NewSearchRequest(
-		dn,
-		ldap.ScopeBaseObject,
-		ldap.NeverDerefAliases,
-		0,
-		0,
-		false,
-		"(objectClass=*)",
-		[]string{attribute},
-		nil,
-	)
-}
-
-func (b *LDAPBackend) readAttributeValues(ctx context.Context, dn, attribute string) []string {
-	req := singleAttributeQuery(dn, attribute)
-	result, err := b.conn.Search(ctx, req)
-	if err != nil {
-		return nil
-	}
-	if len(result.Entries) < 1 {
-		return nil
+func (tx *backendTX) SetUserPassword(ctx context.Context, user *accountserver.User, encryptedPassword string) error {
+	dn := tx.getUserDN(user)
+	tx.setAttr(dn, passwordLDAPAttr, encryptedPassword)
+	for _, r := range user.GetResourcesByType(accountserver.ResourceTypeEmail) {
+		dn, _ = tx.backend.resources.GetDN(r.ID)
+		tx.setAttr(dn, passwordLDAPAttr, encryptedPassword)
 	}
-	return result.Entries[0].GetAttributeValues(attribute)
-}
-
-func (b *LDAPBackend) readAttributeValue(ctx context.Context, dn, attribute string) string {
-	req := singleAttributeQuery(dn, attribute)
-	result, err := b.conn.Search(ctx, req)
-	if err != nil {
-		return ""
-	}
-	if len(result.Entries) < 1 {
-		return ""
-	}
-	return result.Entries[0].GetAttributeValue(attribute)
+	return nil
 }
 
-func (b *LDAPBackend) SetUserPassword(ctx context.Context, user *accountserver.User, encryptedPassword string) error {
-	mod := ldap.NewModifyRequest(getUserDN(user))
-	mod.Replace("userPassword", []string{encryptedPassword})
-	return b.conn.Modify(ctx, mod)
+func (tx *backendTX) SetPasswordRecoveryHint(ctx context.Context, user *accountserver.User, hint, response string) error {
+	dn := tx.getUserDN(user)
+	tx.setAttr(dn, recoveryHintLDAPAttr, hint)
+	tx.setAttr(dn, recoveryResponseLDAPAttr, response)
+	return nil
 }
 
-func (b *LDAPBackend) GetUserEncryptionKeys(ctx context.Context, user *accountserver.User) ([]*accountserver.UserEncryptionKey, error) {
-	rawKeys := b.readAttributeValues(ctx, getUserDN(user), "storageEncryptionKey")
-	return accountserver.DecodeUserEncryptionKeys(rawKeys), nil
+func (tx *backendTX) GetUserEncryptionKeys(ctx context.Context, user *accountserver.User) ([]*accountserver.UserEncryptionKey, error) {
+	r := user.GetSingleResourceByType(accountserver.ResourceTypeEmail)
+	dn, _ := tx.backend.resources.GetDN(r.ID)
+	rawKeys := tx.readAttributeValues(ctx, dn, storagePrivateKeyLDAPAttr)
+	return decodeUserEncryptionKeys(rawKeys), nil
 }
 
-func (b *LDAPBackend) SetUserEncryptionKeys(ctx context.Context, user *accountserver.User, keys []*accountserver.UserEncryptionKey) error {
-	mod := ldap.NewModifyRequest(getUserDN(user))
-	if user.HasEncryptionKeys {
-		mod.Replace("storageEncryptionKey", accountserver.EncodeUserEncryptionKeys(keys))
-	} else {
-		mod.Add("storageEncryptionKey", accountserver.EncodeUserEncryptionKeys(keys))
+func (tx *backendTX) SetUserEncryptionKeys(ctx context.Context, user *accountserver.User, keys []*accountserver.UserEncryptionKey) error {
+	encKeys := encodeUserEncryptionKeys(keys)
+	for _, r := range user.GetResourcesByType(accountserver.ResourceTypeEmail) {
+		dn, _ := tx.backend.resources.GetDN(r.ID)
+		tx.setAttr(dn, storagePrivateKeyLDAPAttr, encKeys...)
 	}
-	return b.conn.Modify(ctx, mod)
+	return nil
 }
 
-func (b *LDAPBackend) SetUserEncryptionPublicKey(ctx context.Context, user *accountserver.User, pub []byte) error {
-	mod := ldap.NewModifyRequest(getUserDN(user))
-	if user.HasEncryptionKeys {
-		mod.Replace("storageEncryptionPublicKey", []string{string(pub)})
-	} else {
-		mod.Add("storageEncryptionPublicKey", []string{string(pub)})
+func (tx *backendTX) SetUserEncryptionPublicKey(ctx context.Context, user *accountserver.User, pub []byte) error {
+	for _, r := range user.GetResourcesByType(accountserver.ResourceTypeEmail) {
+		dn, _ := tx.backend.resources.GetDN(r.ID)
+		tx.setAttr(dn, storagePublicKeyLDAPAttr, string(pub))
 	}
-	return b.conn.Modify(ctx, mod)
+	return nil
 }
 
-func (b *LDAPBackend) SetApplicationSpecificPassword(ctx context.Context, user *accountserver.User, info *accountserver.AppSpecificPasswordInfo, encryptedPassword string) error {
-	emailRsrc := user.GetSingleResourceByType(accountserver.ResourceTypeEmail)
-	if emailRsrc == nil {
-		return errors.New("no email resource")
-	}
-	emailDN := getResourceDN(emailRsrc)
-
-	asps := decodeAppSpecificPasswords(b.readAttributeValues(ctx, emailDN, "appSpecificPassword"))
-	var outASPs []*appSpecificPassword
+func excludeASPFromList(asps []*appSpecificPassword, id string) []*appSpecificPassword {
+	var out []*appSpecificPassword
 	for _, asp := range asps {
-		if asp.ID != info.ID {
-			outASPs = append(outASPs, asp)
+		if asp.ID != id {
+			out = append(out, asp)
 		}
 	}
-	outASPs = append(outASPs, newAppSpecificPassword(*info, encryptedPassword))
-
-	mod := ldap.NewModifyRequest(emailDN)
-	if len(asps) > 0 {
-		mod.Replace("appSpecificPassword", encodeAppSpecificPasswords(outASPs))
-	} else {
-		mod.Add("appSpecificPassword", encodeAppSpecificPasswords(outASPs))
-	}
-	return b.conn.Modify(ctx, mod)
+	return out
 }
 
-func (b *LDAPBackend) DeleteApplicationSpecificPassword(ctx context.Context, user *accountserver.User, id string) error {
-	emailRsrc := user.GetSingleResourceByType(accountserver.ResourceTypeEmail)
-	if emailRsrc == nil {
-		return errors.New("no email resource")
-	}
-	emailDN := getResourceDN(emailRsrc)
+func (tx *backendTX) setASPOnResource(ctx context.Context, r *accountserver.Resource, info *accountserver.AppSpecificPasswordInfo, encryptedPassword string) {
+	dn, _ := tx.backend.resources.GetDN(r.ID)
 
-	asps := decodeAppSpecificPasswords(b.readAttributeValues(ctx, emailDN, "appSpecificPassword"))
-	var outASPs []*appSpecificPassword
-	for _, asp := range asps {
-		if asp.ID != id {
-			outASPs = append(outASPs, asp)
-		}
-	}
+	// Obtain the full list of ASPs from the backend and replace/append the new one.
+	asps := decodeAppSpecificPasswords(tx.readAttributeValues(ctx, dn, aspLDAPAttr))
+	asps = append(excludeASPFromList(asps, info.ID), newAppSpecificPassword(*info, encryptedPassword))
+	outASPs := encodeAppSpecificPasswords(asps)
+	tx.setAttr(dn, aspLDAPAttr, outASPs...)
+}
 
-	mod := ldap.NewModifyRequest(emailDN)
-	if len(outASPs) == 0 {
-		mod.Delete("appSpecificPassword", encodeAppSpecificPasswords(asps))
-	} else if len(asps) > 0 {
-		mod.Replace("appSpecificPassword", encodeAppSpecificPasswords(outASPs))
-	} else {
-		mod.Add("appSpecificPassword", encodeAppSpecificPasswords(outASPs))
+func (tx *backendTX) SetApplicationSpecificPassword(ctx context.Context, user *accountserver.User, info *accountserver.AppSpecificPasswordInfo, encryptedPassword string) error {
+	for _, r := range user.GetResourcesByType(accountserver.ResourceTypeEmail) {
+		tx.setASPOnResource(ctx, r, info, encryptedPassword)
 	}
-	return b.conn.Modify(ctx, mod)
+	return nil
+}
+
+func (tx *backendTX) deleteASPOnResource(ctx context.Context, r *accountserver.Resource, id string) {
+	dn, _ := tx.backend.resources.GetDN(r.ID)
+	asps := decodeAppSpecificPasswords(tx.readAttributeValues(ctx, dn, aspLDAPAttr))
+	asps = excludeASPFromList(asps, id)
+	outASPs := encodeAppSpecificPasswords(asps)
+	tx.setAttr(dn, aspLDAPAttr, outASPs...)
 }
 
-func (b *LDAPBackend) SetUserTOTPSecret(ctx context.Context, user *accountserver.User, secret string) error {
-	mod := ldap.NewModifyRequest(getUserDN(user))
-	if b.readAttributeValue(ctx, getUserDN(user), "totpSecret") == "" {
-		mod.Add("totpSecret", []string{secret})
-	} else {
-		mod.Replace("totpSecret", []string{secret})
+func (tx *backendTX) DeleteApplicationSpecificPassword(ctx context.Context, user *accountserver.User, id string) error {
+	for _, r := range user.GetResourcesByType(accountserver.ResourceTypeEmail) {
+		tx.deleteASPOnResource(ctx, r, id)
 	}
-	return b.conn.Modify(ctx, mod)
+	return nil
 }
 
-func (b *LDAPBackend) DeleteUserTOTPSecret(ctx context.Context, user *accountserver.User) error {
-	mod := ldap.NewModifyRequest(getUserDN(user))
-	mod.Delete("totpSecret", nil)
-	return b.conn.Modify(ctx, mod)
+func (tx *backendTX) SetUserTOTPSecret(ctx context.Context, user *accountserver.User, secret string) error {
+	tx.setAttr(tx.getUserDN(user), totpSecretLDAPAttr, secret)
+	return nil
 }
 
-func (b *LDAPBackend) SetResourcePassword(ctx context.Context, _ string, r *accountserver.Resource, encryptedPassword string) error {
-	mod := ldap.NewModifyRequest(getResourceDN(r))
-	mod.Replace("userPassword", []string{encryptedPassword})
-	return b.conn.Modify(ctx, mod)
+func (tx *backendTX) DeleteUserTOTPSecret(ctx context.Context, user *accountserver.User) error {
+	tx.setAttr(tx.getUserDN(user), totpSecretLDAPAttr)
+	return nil
 }
 
-func parseResourceID(resourceID string) (string, string) {
-	parts := strings.SplitN(resourceID, "/", 2)
-	return parts[0], parts[1]
+func (tx *backendTX) SetResourcePassword(ctx context.Context, r *accountserver.Resource, encryptedPassword string) error {
+	dn, _ := tx.backend.resources.GetDN(r.ID)
+	tx.setAttr(dn, passwordLDAPAttr, encryptedPassword)
+	return nil
 }
 
-func (b *LDAPBackend) hasResource(ctx context.Context, resourceID string) (bool, error) {
-	resourceType, resourceName := parseResourceID(resourceID)
-	query, ok := b.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.
-	result, err := b.conn.Search(ctx, query.searchRequest(map[string]string{
+	result, err := tx.search(ctx, query.searchRequest(map[string]string{
 		"resource": resourceName,
 		"type":     resourceType,
 	}, []string{"dn"}))
@@ -606,9 +323,9 @@ func (b *LDAPBackend) hasResource(ctx context.Context, resourceID string) (bool,
 }
 
 // HasAnyResource returns true if any of the specified resources exists.
-func (b *LDAPBackend) HasAnyResource(ctx context.Context, resourceIDs []string) (bool, error) {
-	for _, resourceID := range resourceIDs {
-		has, err := b.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
 		}
@@ -617,18 +334,28 @@ func (b *LDAPBackend) HasAnyResource(ctx context.Context, resourceIDs []string)
 }
 
 // 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")
+func (tx *backendTX) GetResource(ctx context.Context, rsrcID accountserver.ResourceID) (*accountserver.Resource, error) {
+	// 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
 	}
 
-	result, err := b.conn.Search(ctx, query.searchRequest(map[string]string{
-		"user":     username,
-		"resource": resourceName,
-		"type":     resourceType,
-	}, nil))
+	// 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
@@ -636,70 +363,39 @@ func (b *LDAPBackend) GetResource(ctx context.Context, username, resourceID stri
 		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 (b *LDAPBackend) UpdateResource(ctx context.Context, _ string, r *accountserver.Resource) error {
-	lo, ok := r.Opaque.(*ldapObjectData)
-	if !ok || lo == nil {
-		return errors.New("resource did not come from GetResource")
+// CreateResource creates a new LDAP-backed resource object.
+func (tx *backendTX) CreateResource(ctx context.Context, r *accountserver.Resource) error {
+	dn, err := tx.backend.resources.GetDN(r.ID)
+	if err != nil {
+		return err
 	}
 
-	modRequest := createModifyRequest(lo.dn, lo.original, r)
-	if modRequest == nil {
-		return nil
+	tx.create(dn)
+	for _, attr := range tx.backend.resources.ToLDAP(r) {
+		tx.setAttr(dn, attr.Type, attr.Vals...)
 	}
 
-	return b.conn.Modify(ctx, modRequest)
-}
-
-type ldapObjectData struct {
-	dn       string
-	original *accountserver.Resource
-}
-
-func getResourceDN(r *accountserver.Resource) string {
-	lo, ok := r.Opaque.(*ldapObjectData)
-	if !ok {
-		panic("no ldap resource data")
-	}
-	return lo.dn
+	return nil
 }
 
-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")
-	}
+// UpdateResource updates a LDAP-backed resource that was obtained by a previous GetResource call.
+func (tx *backendTX) UpdateResource(ctx context.Context, r *accountserver.Resource) error {
+	dn, err := tx.backend.resources.GetDN(r.ID)
 	if err != nil {
-		return
-	}
-	r.Opaque = &ldapObjectData{
-		dn:       entry.DN,
-		original: r.Copy(),
+		return err
 	}
-	return
-}
 
-func isObjectClass(entry *ldap.Entry, class string) bool {
-	classes := entry.GetAttributeValues("objectClass")
-	for _, c := range classes {
-		if c == class {
-			return true
-		}
+	// We can simply dump all attribute/value pairs and let the
+	// code in ldapTX do the work of finding the differences.
+	for _, attr := range tx.backend.resources.ToLDAP(r) {
+		tx.setAttr(dn, attr.Type, attr.Vals...)
 	}
-	return false
+
+	return nil
 }
 
 var siteRoot = "/home/users/investici.org/"
@@ -723,18 +419,18 @@ 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 {
-		case accountserver.ResourceTypeWebsite:
+		switch r.ID.Type() {
+		case accountserver.ResourceTypeWebsite, accountserver.ResourceTypeDomain:
 			r.Group = getHostingDir(r.Website.DocumentRoot)
-			webs[r.ID] = r
+			webs[r.ID.String()] = r
 		case accountserver.ResourceTypeDAV:
 			r.Group = getHostingDir(r.DAV.Homedir)
 		}
 	}
 	// Fix databases in a second pass.
 	for _, r := range resources {
-		if r.Type == accountserver.ResourceTypeDatabase && r.ParentID != "" {
-			r.Group = webs[r.ParentID].Group
+		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 da98a09cd65249b8c66449f3454e34732b43b90e..c99c8f62daf6c08264db675e318712af2d502714 100644
--- a/backend/model_test.go
+++ b/backend/model_test.go
@@ -1,88 +1,302 @@
 package backend
 
 import (
-	"reflect"
+	"context"
 	"testing"
 
+	"github.com/go-test/deep"
+
 	"git.autistici.org/ai3/accountserver"
-	ldap "gopkg.in/ldap.v2"
+	"git.autistici.org/ai3/accountserver/ldaptest"
+)
+
+const (
+	testLDAPPort = 42871
+	testLDAPAddr = "ldap://127.0.0.1:42871"
+	testUser1    = "uno@investici.org"
+	testUser2    = "due@investici.org"
 )
 
-// Compare resources, ignoring the Opaque member.
-func resourcesEqual(a, b *accountserver.Resource) bool {
-	aa := *a
-	bb := *b
-	aa.Opaque = nil
-	bb.Opaque = nil
-	return reflect.DeepEqual(aa, bb)
+func startServerAndGetUser(t testing.TB) (func(), accountserver.Backend, *accountserver.User) {
+	return startServerAndGetUserWithName(t, testUser1)
+}
+
+func startServerAndGetUser2(t testing.TB) (func(), accountserver.Backend, *accountserver.User) {
+	return startServerAndGetUserWithName(t, testUser2)
 }
 
-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 startServerAndGetUserWithName(t testing.TB, username string) (func(), accountserver.Backend, *accountserver.User) {
+	stop := ldaptest.StartServer(t, &ldaptest.Config{
+		Dir:  "../ldaptest",
+		Port: testLDAPPort,
+		Base: "dc=example,dc=com",
+		LDIFs: []string{
+			"testdata/base.ldif",
+			"testdata/test1.ldif",
+			"testdata/test2.ldif",
 		},
-	)
+	})
 
-	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)
-	}
-
-	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",
-		},
+		t.Fatal("NewLDAPBackend", err)
+	}
+
+	tx, _ := b.NewTransaction()
+	user, err := tx.GetUser(context.Background(), username)
+	if err != nil {
+		t.Fatal("GetUser", err)
 	}
-	if !resourcesEqual(r, expected) {
-		t.Fatalf("bad result: got %+v, expected %+v", r, expected)
+	if user == nil {
+		t.Fatalf("could not find test user %s", username)
 	}
+
+	return stop, b, user
 }
 
-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_GetUser(t *testing.T) {
+	stop, _, user := startServerAndGetUser(t)
+	defer stop()
+
+	if user.Name != testUser1 {
+		t.Fatalf("bad username: expected %s, got %s", testUser1, user.Name)
+	}
+	if len(user.Resources) != 5 {
+		t.Fatalf("expected 5 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 err := deep.Equal(db, expectedDB); err != nil {
+		t.Fatalf("returned database resource differs: %v", err)
+	}
+}
+
+func TestModel_GetUser_Has2FA(t *testing.T) {
+	stop, _, user := startServerAndGetUser2(t)
+	defer stop()
 
-	r, err := parseLdapResource(entry)
+	if !user.Has2FA {
+		t.Errorf("user %s does not appear to have 2FA enabled", testUser2)
+	}
+	if !user.HasEncryptionKeys {
+		t.Errorf("user %s does not appear to have encryption keys", testUser2)
+	}
+}
+
+func TestModel_GetUser_Group(t *testing.T) {
+	stop, _, user := startServerAndGetUser(t)
+	defer stop()
+
+	var grouped []*accountserver.Resource
+	for _, r := range user.Resources {
+		switch r.ID.Type() {
+		case accountserver.ResourceTypeWebsite, accountserver.ResourceTypeDomain, accountserver.ResourceTypeDAV, accountserver.ResourceTypeDatabase:
+			grouped = append(grouped, r)
+		}
+	}
+
+	var group string
+	for _, r := range grouped {
+		if r.Group == "" {
+			t.Errorf("group not set on %s", r.ID)
+			continue
+		}
+		if group == "" {
+			group = r.Group
+		} else if group != r.Group {
+			t.Errorf("wrong group on %s (%s, expected %s)", r.ID, r.Group, group)
+		}
+	}
+}
+
+func TestModel_GetUser_Resources(t *testing.T) {
+	stop, b, user := startServerAndGetUser(t)
+	defer stop()
+
+	// Fetch individually all user resources, one by one, and
+	// check that they match what we have already.
+	tx2, _ := b.NewTransaction()
+	for _, r := range user.Resources {
+		fr, err := tx2.GetResource(context.Background(), r.ID)
+		if err != nil {
+			t.Errorf("could not fetch resource %s: %v", r.ID, err)
+			continue
+		}
+		if fr == nil {
+			t.Errorf("resource %s is missing", r.ID)
+			continue
+		}
+		// It's ok if Group is unset in the GetResource response.
+		rr := *r
+		rr.Group = ""
+		if err := deep.Equal(fr, &rr); err != nil {
+			t.Errorf("error in fetched resource %s: %v", r.ID, err)
+			continue
+		}
+	}
+}
+
+func TestModel_SetResourceStatus(t *testing.T) {
+	stop := ldaptest.StartServer(t, &ldaptest.Config{
+		Dir:   "../ldaptest",
+		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(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 := accountserver.NewResourceID(accountserver.ResourceTypeEmail, testUser1, testUser1)
+	r, err := tx.GetResource(context.Background(), rsrcID)
+	if err != nil {
+		t.Fatal("GetResource", err)
+	}
+	if r == nil {
+		t.Fatalf("could not find test resource %s", rsrcID)
 	}
 
-	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(), r); err != nil {
+		t.Fatal("UpdateResource", err)
+	}
+	if err := tx.Commit(context.Background()); err != nil {
+		t.Fatalf("commit error: %v", err)
+	}
+}
+
+func TestModel_HasAnyResource(t *testing.T) {
+	stop := ldaptest.StartServer(t, &ldaptest.Config{
+		Dir:   "../ldaptest",
+		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)
+	}
+
+	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")
+	}
+}
+
+func TestModel_SetUserPassword(t *testing.T) {
+	stop, b, user := startServerAndGetUser(t)
+	defer stop()
+
+	encPass := "encrypted password"
+
+	tx, _ := b.NewTransaction()
+	if err := tx.SetUserPassword(context.Background(), user, encPass); err != nil {
+		t.Fatal("SetUserPassword", err)
+	}
+	if err := tx.Commit(context.Background()); err != nil {
+		t.Fatal("Commit", err)
+	}
+
+	// Verify that the new password is set.
+	pwattr := tx.(*backendTX).readAttributeValues(
+		context.Background(),
+		"mail=uno@investici.org,uid=uno@investici.org,ou=People,dc=example,dc=com",
+		"userPassword",
+	)
+	if len(pwattr) == 0 {
+		t.Fatalf("no userPassword attribute on mail= object")
+	}
+	if len(pwattr) > 1 {
+		t.Fatalf("more than one userPassword found on mail= object")
+	}
+	if pwattr[0] != encPass {
+		t.Fatalf("bad userPassword, got %s, expected %s", pwattr[0], encPass)
+	}
+}
+
+func TestModel_SetUserEncryptionKeys_Add(t *testing.T) {
+	stop, b, user := startServerAndGetUser(t)
+	defer stop()
+
+	tx, _ := b.NewTransaction()
+	keys := []*accountserver.UserEncryptionKey{
+		{
+			ID:  accountserver.UserEncryptionKeyMainID,
+			Key: []byte("very secret key"),
+		},
+	}
+	if err := tx.SetUserEncryptionKeys(context.Background(), user, keys); err != nil {
+		t.Fatal("SetUserEncryptionKeys", err)
+	}
+	if err := tx.Commit(context.Background()); err != nil {
+		t.Fatal("Commit", err)
+	}
+}
+
+func TestModel_SetUserEncryptionKeys_Replace(t *testing.T) {
+	stop, b, user := startServerAndGetUser2(t)
+	defer stop()
+
+	tx, _ := b.NewTransaction()
+	keys := []*accountserver.UserEncryptionKey{
+		{
+			ID:  accountserver.UserEncryptionKeyMainID,
+			Key: []byte("very secret key"),
+		},
+	}
+	if err := tx.SetUserEncryptionKeys(context.Background(), user, keys); err != nil {
+		t.Fatal("SetUserEncryptionKeys", err)
+	}
+	if err := tx.Commit(context.Background()); err != nil {
+		t.Fatal("Commit", err)
 	}
 }
diff --git a/backend/resources.go b/backend/resources.go
new file mode 100644
index 0000000000000000000000000000000000000000..3da6cea0a949de7bf4c8ba6dc78c3a3d2b92cc50
--- /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("ftpname=${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, 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 0000000000000000000000000000000000000000..e7c5e21631da8a33f64da36139e2443ce154a4d0
--- /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
new file mode 100644
index 0000000000000000000000000000000000000000..fcbc2849fa34661c06ccd104a91052e2fa326dde
--- /dev/null
+++ b/backend/testdata/base.ldif
@@ -0,0 +1,16 @@
+
+dn: dc=example,dc=com
+objectclass: domain
+objectclass: top
+dc: example
+
+dn: ou=People,dc=example,dc=com
+objectclass: top
+objectclass: organizationalUnit
+ou: People
+
+dn: ou=Lists,dc=example,dc=com
+objectclass: top
+objectclass: organizationalUnit
+ou: Lists
+
diff --git a/backend/testdata/test1.ldif b/backend/testdata/test1.ldif
new file mode 100644
index 0000000000000000000000000000000000000000..9bf6880683b247b165f7b48ccd62866470c24af8
--- /dev/null
+++ b/backend/testdata/test1.ldif
@@ -0,0 +1,106 @@
+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: cn=example.com,uid=uno@investici.org,ou=People,dc=example,dc=com
+status: active
+acceptMail: true
+objectClass: top
+objectClass: virtualHost
+cn: example.com
+host: host2
+documentRoot: /home/users/investici.org/uno/html-example.com
+creationDate: 02-08-2013
+originalHost: host2
+statsId: 2192
+
+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/testdata/test2.ldif b/backend/testdata/test2.ldif
new file mode 100644
index 0000000000000000000000000000000000000000..e4fa0dfdc52a73a516d2add396a45368fc0550ad
--- /dev/null
+++ b/backend/testdata/test2.ldif
@@ -0,0 +1,37 @@
+dn: uid=due@investici.org,ou=People,dc=example,dc=com
+cn: due@investici.org
+gecos: due@investici.org
+gidNumber: 2000
+givenName: Private
+homeDirectory: /var/empty
+loginShell: /bin/false
+objectClass: top
+objectClass: person
+objectClass: posixAccount
+objectClass: shadowAccount
+objectClass: organizationalPerson
+objectClass: inetOrgPerson
+shadowLastChange: 12345
+shadowMax: 99999
+shadowWarning: 7
+sn: due@investici.org
+uid: due@investici.org
+uidNumber: 256799
+userPassword:: JGEyJDQkMzI3NjgkMSQwZDgyMzU1YjQ0Mzg0M2NmZDY4MjU1MzE4ZTVjYTdiZSRmNTQ0ODkxOTFiNWZlYzk2MDRlNWQ2ODZjMDQxZjJkNTFmOTgxOGY4ZTFmM2E4MDYzY2U3ZTEwMTE3OTc2OGI0
+totpSecret: ABCDEFGH
+
+dn: mail=due@investici.org,uid=due@investici.org,ou=People,dc=example,dc=com
+creationDate: 01-08-2013
+gidNumber: 2000
+host: host2
+mail: due@investici.org
+mailMessageStore: investici.org/due/
+objectClass: top
+objectClass: virtualMailUser
+originalHost: host2
+status: active
+uidNumber: 256799
+userPassword:: JGEyJDQkMzI3NjgkMSQwZDgyMzU1YjQ0Mzg0M2NmZDY4MjU1MzE4ZTVjYTdiZSRmNTQ0ODkxOTFiNWZlYzk2MDRlNWQ2ODZjMDQxZjJkNTFmOTgxOGY4ZTFmM2E4MDYzY2U3ZTEwMTE3OTc2OGI0
+storagePublicKey:: LS0tLS1CRUdJTiBQVUJMSUMgS0VZLS0tLS0KTUU0d0VBWUhLb1pJemowQ0FRWUZLNEVFQUNFRE9nQUVTeVVyVFhaaHRTeFpreityQjYwaFM4VnhINWozM3Ftbgphb3h2WG9IeG9vYU9Sc0x5TXNnVE5RVDR1bU1XdU12U3ROamszeWdWR2FNPQotLS0tLUVORCBQVUJMSUMgS0VZLS0tLS0K
+storageEncryptedSecretKey:: rffWTx7AjmhqD8li78Tal+7zfIbOFSyX4sKxKFa/bj5XAlWLyq7ANWiJB/0PRC1y8JBg+ezti5DjC5Ft82f5uwb2+3vIxjrxyz5vAmSUjRYo7o9alu5vLXRapsyhiYgJGmJrBJZxkQ9rGDXsM4OfZQNlxP4AVMobQFQU9X4QBUWFo2MwKuvwQiHg359hLufUrmr2bmjzPsU5Uj+8vAeQWHsVxWuUwUuob630A2V619iO5cp5nPzk5itYMNkdl1eR3KvUonvqwz++HLRJNqwh7qn2CjdUIA5ljexFg88UbNbzrpa+6Atmd4iXieYPewYHPHtuRFV3eHHlnBbv8VMcQdVZ0sqJokWDRvFjJbg=
+
diff --git a/backend/tx.go b/backend/tx.go
new file mode 100644
index 0000000000000000000000000000000000000000..85a8fae9dfa0e260a9af175db2d2b56d6e491ade
--- /dev/null
+++ b/backend/tx.go
@@ -0,0 +1,225 @@
+package backend
+
+import (
+	"context"
+	"log"
+	"strings"
+
+	"gopkg.in/ldap.v2"
+)
+
+// Generic interface to LDAP - allows us to stub out the LDAP client while
+// 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()
+}
+
+type ldapAttr struct {
+	dn, attr string
+	values   []string
+}
+
+// An LDAP "transaction" is really just a buffer of attribute changes,
+// that are all executed at once at Commit() time.
+//
+// Unfortunately, in order to issue LDAP Modify requests properly
+// (which have separate Add and Replace options, for one), we need to
+// keep state about the observed data, so we cache the results of all
+// Search operations and compare those with the new data at commit
+// time. If you attempt to modify an object that you haven't Searched
+// for previously in the same transaction, you're most likely to get a
+// LDAP error in return. Which is fine because all our workflows are
+// read/modify/update ones anyway.
+//
+// Since ordering of Modify requests is important in LDAP, this object
+// will preserve the ordering of DNs and attributes when calling
+// Commit().
+//
+type ldapTX struct {
+	conn ldapConn
+
+	cache   map[string][]string
+	newDNs  map[string]struct{}
+	changes []ldapAttr
+}
+
+func newLDAPTX(conn ldapConn) *ldapTX {
+	return &ldapTX{
+		conn:   conn,
+		cache:  make(map[string][]string),
+		newDNs: make(map[string]struct{}),
+	}
+}
+
+func cacheKey(dn, attr string) string {
+	return strings.Join([]string{dn, attr}, ";")
+}
+
+// Search wrapper that fills the cache.
+func (tx *ldapTX) search(ctx context.Context, req *ldap.SearchRequest) (*ldap.SearchResult, error) {
+	res, err := tx.conn.Search(ctx, req)
+	if err != nil {
+		return nil, err
+	}
+
+	for _, entry := range res.Entries {
+		for _, attr := range entry.Attributes {
+			tx.cache[cacheKey(entry.DN, attr.Name)] = attr.Values
+		}
+	}
+
+	return res, nil
+}
+
+// Announce the intention to create a new object. To be called before
+// setAttr() on the new DN.
+func (tx *ldapTX) create(dn string) {
+	tx.newDNs[dn] = struct{}{}
+}
+
+// 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})
+}
+
+// Commit the transaction, sending all changes to the LDAP server.
+func (tx *ldapTX) Commit(ctx context.Context) error {
+	// Iterate through the changes, and generate ModifyRequest
+	// objects grouped by DN (while preserving the order of DNs).
+	adds, mods, dns := tx.aggregateChanges(ctx)
+
+	// Now issue all Modify or Add requests, one by one, in the
+	// same order as we have seen them. Abort on the first error.
+	for _, dn := range dns {
+		var err error
+		if ar, ok := adds[dn]; ok {
+			if isEmptyAddRequest(ar) {
+				continue
+			}
+			log.Printf("issuing AddRequest: %+v", ar)
+			err = tx.conn.Add(ctx, ar)
+		} else {
+			mr := mods[dn]
+			if isEmptyModifyRequest(mr) {
+				continue
+			}
+			log.Printf("issuing ModifyRequest: %+v", mr)
+			err = tx.conn.Modify(ctx, mr)
+		}
+		if err != nil {
+			return err
+		}
+	}
+
+	// Cleanup
+	tx.changes = nil
+	tx.newDNs = make(map[string]struct{})
+
+	return nil
+}
+
+// Helper for Commit that aggregates changes into add and modify lists.
+func (tx *ldapTX) aggregateChanges(ctx context.Context) (map[string]*ldap.AddRequest, map[string]*ldap.ModifyRequest, []string) {
+	var dns []string
+	mods := make(map[string]*ldap.ModifyRequest)
+	adds := make(map[string]*ldap.AddRequest)
+	for _, c := range tx.changes {
+		if _, isNew := tx.newDNs[c.dn]; isNew {
+			ar, ok := adds[c.dn]
+			if !ok {
+				ar = ldap.NewAddRequest(c.dn)
+				adds[c.dn] = ar
+				dns = append(dns, c.dn)
+			}
+			if len(c.values) > 0 {
+				ar.Attribute(c.attr, c.values)
+			}
+		} else {
+			mr, ok := mods[c.dn]
+			if !ok {
+				mr = ldap.NewModifyRequest(c.dn)
+				mods[c.dn] = mr
+				dns = append(dns, c.dn)
+			}
+			tx.updateModifyRequest(ctx, mr, c)
+		}
+	}
+	return adds, mods, dns
+}
+
+func (tx *ldapTX) updateModifyRequest(ctx context.Context, mr *ldap.ModifyRequest, attr ldapAttr) {
+	old, ok := tx.cache[cacheKey(attr.dn, attr.attr)]
+
+	// Pessimistic approach: if we haven't seen this attribute
+	// before, try to fetch it from LDAP so we know if we need to
+	// perform an Add or a Replace.
+	if !ok {
+		log.Printf("tx: pessimistic fallback for %s %s", attr.dn, attr.attr)
+		oldFromLDAP := tx.readAttributeValues(ctx, attr.dn, attr.attr)
+		if len(oldFromLDAP) > 0 {
+			ok = true
+			old = oldFromLDAP
+		}
+	}
+
+	switch {
+	case ok && !stringListEquals(old, attr.values):
+		mr.Replace(attr.attr, attr.values)
+	case ok && attr.values == nil:
+		mr.Delete(attr.attr, old)
+	case !ok && len(attr.values) > 0:
+		mr.Add(attr.attr, attr.values)
+	}
+}
+
+func (tx *ldapTX) readAttributeValues(ctx context.Context, dn, attr string) []string {
+	result, err := tx.search(ctx, ldap.NewSearchRequest(
+		dn,
+		ldap.ScopeBaseObject,
+		ldap.NeverDerefAliases,
+		0,
+		0,
+		false,
+		"(objectClass=*)",
+		[]string{attr},
+		nil,
+	))
+	if err == nil && len(result.Entries) > 0 {
+		return result.Entries[0].GetAttributeValues(attr)
+	}
+	return nil
+}
+
+func isEmptyModifyRequest(mr *ldap.ModifyRequest) bool {
+	return (len(mr.AddAttributes) == 0 &&
+		len(mr.DeleteAttributes) == 0 &&
+		len(mr.ReplaceAttributes) == 0)
+}
+
+func isEmptyAddRequest(ar *ldap.AddRequest) bool {
+	return len(ar.Attributes) == 0
+}
+
+// Unordered list comparison.
+func stringListEquals(a, b []string) bool {
+	if len(a) != len(b) {
+		return false
+	}
+	tmp := make(map[string]struct{})
+	for _, aa := range a {
+		tmp[aa] = struct{}{}
+	}
+	for _, bb := range b {
+		if _, ok := tmp[bb]; !ok {
+			return false
+		}
+	}
+	return true
+}
diff --git a/backend/util.go b/backend/util.go
new file mode 100644
index 0000000000000000000000000000000000000000..b7037cc4d76fc9f28a70adc0b5eccb802f90dbb1
--- /dev/null
+++ b/backend/util.go
@@ -0,0 +1,98 @@
+package backend
+
+import (
+	"errors"
+	"os"
+
+	ldaputil "git.autistici.org/ai3/go-common/ldap"
+	"gopkg.in/ldap.v2"
+)
+
+// queryConfig holds the parameters for a single LDAP query.
+type queryConfig struct {
+	Base        string
+	Filter      string
+	Scope       string
+	parsedScope int
+}
+
+func (q *queryConfig) validate() error {
+	if q.Base == "" {
+		return errors.New("empty search base")
+	}
+	// An empty filter is equivalent to objectClass=*.
+	if q.Filter == "" {
+		q.Filter = "(objectClass=*)"
+	}
+	q.parsedScope = ldap.ScopeWholeSubtree
+	if q.Scope != "" {
+		s, err := ldaputil.ParseScope(q.Scope)
+		if err != nil {
+			return err
+		}
+		q.parsedScope = s
+	}
+	return nil
+}
+
+func (q *queryConfig) searchRequest(vars map[string]string, attrs []string) *ldap.SearchRequest {
+	return ldap.NewSearchRequest(
+		replaceVars(q.Base, vars),
+		q.parsedScope,
+		ldap.NeverDerefAliases,
+		0,
+		0,
+		false,
+		replaceVars(q.Filter, vars),
+		attrs,
+		nil,
+	)
+}
+
+func mustCompileQueryConfig(q *queryConfig) *queryConfig {
+	if err := q.validate(); err != nil {
+		panic(err)
+	}
+	return q
+}
+
+func replaceVars(s string, vars map[string]string) string {
+	return os.Expand(s, func(k string) string {
+		return ldap.EscapeFilter(vars[k])
+	})
+}
+
+func s2b(s string) bool {
+	switch s {
+	case "yes", "y", "on", "enabled", "true":
+		return true
+	default:
+		return false
+	}
+}
+
+func b2s(b bool) string {
+	if b {
+		return "yes"
+	}
+	return "no"
+}
+
+// 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 isObjectClass(entry *ldap.Entry, class string) bool {
+	classes := entry.GetAttributeValues("objectClass")
+	for _, c := range classes {
+		if c == class {
+			return true
+		}
+	}
+	return false
+}
diff --git a/cmd/accountserver/main.go b/cmd/accountserver/main.go
index a1672b4bfdd0218d9c8ca04e9caf4f1c5ce79f8e..90b44bd85e73726b2b99948a8dedcef1ff603a12 100644
--- a/cmd/accountserver/main.go
+++ b/cmd/accountserver/main.go
@@ -113,7 +113,7 @@ func main() {
 		log.Fatal(err)
 	}
 
-	as := server.New(service)
+	as := server.New(service, be)
 
 	if err := serverutil.Serve(as.Handler(), config.ServerConfig, *addr); err != nil {
 		log.Fatal(err)
diff --git a/config.go b/config.go
index 42d95f3a414c215e3e7e070398393b5b211e21f3..f4e1912a254bcc60700bc42f18e6fece85b1e38e 100644
--- a/config.go
+++ b/config.go
@@ -6,9 +6,19 @@ import (
 	"git.autistici.org/id/go-sso"
 )
 
+// Config holds the configuration for the AccountService.
 type Config struct {
-	ForbiddenUsernames []string            `yaml:"forbidden_usernames"`
-	AvailableDomains   map[string][]string `yaml:"available_domains"`
+	ForbiddenUsernames     []string            `yaml:"forbidden_usernames"`
+	ForbiddenUsernamesFile string              `yaml:"forbidden_usernames_file"`
+	ForbiddenPasswords     []string            `yaml:"forbidden_passwords"`
+	ForbiddenPasswordsFile string              `yaml:"forbidden_passwords_file"`
+	AvailableDomains       map[string][]string `yaml:"available_domains"`
+	WebsiteRootDir         string              `yaml:"website_root_dir"`
+
+	Shards struct {
+		Available map[string][]string `yaml:"available"`
+		Allowed   map[string][]string `yaml:"allowed"`
+	} `yaml:"shards"`
 
 	SSO struct {
 		PublicKeyFile string   `yaml:"public_key"`
@@ -27,9 +37,46 @@ func (c *Config) domainBackend() domainBackend {
 	return b
 }
 
-func (c *Config) validationConfig() *validationConfig {
-	return &validationConfig{
-		forbiddenUsernames: newStringSetFromList(c.ForbiddenUsernames),
+func (c *Config) shardBackend() shardBackend {
+	b := &staticShardBackend{
+		available: make(map[string]stringSet),
+		allowed:   make(map[string]stringSet),
+	}
+	loadSet := func(target map[string]stringSet, src map[string][]string) {
+		for kind, list := range src {
+			target[kind] = newStringSetFromList(list)
+		}
+	}
+	loadSet(b.available, c.Shards.Available)
+	loadSet(b.allowed, c.Shards.Allowed)
+	return b
+}
+
+func (c *Config) validationContext(be Backend) (*validationContext, error) {
+	fu, err := newStringSetFromFileOrList(c.ForbiddenUsernames, c.ForbiddenUsernamesFile)
+	if err != nil {
+		return nil, err
+	}
+	fp, err := newStringSetFromFileOrList(c.ForbiddenPasswords, c.ForbiddenPasswordsFile)
+	if err != nil {
+		return nil, err
+	}
+	return &validationContext{
+		forbiddenUsernames: fu,
+		forbiddenPasswords: fp,
+		minPasswordLength:  6,
+		maxPasswordLength:  128,
+		webroot:            c.WebsiteRootDir,
+		domains:            c.domainBackend(),
+		shards:             c.shardBackend(),
+		backend:            be,
+	}, nil
+}
+
+func (c *Config) templateContext() *templateContext {
+	return &templateContext{
+		shards:  c.shardBackend(),
+		webroot: c.WebsiteRootDir,
 	}
 }
 
diff --git a/encryption.go b/encryption.go
new file mode 100644
index 0000000000000000000000000000000000000000..ed6549499b7671f039c69e99323957e6ca1e5ab1
--- /dev/null
+++ b/encryption.go
@@ -0,0 +1,71 @@
+package accountserver
+
+import (
+	"context"
+
+	"git.autistici.org/id/keystore/userenckey"
+)
+
+func keysToBytes(keys []*UserEncryptionKey) [][]byte {
+	var rawKeys [][]byte
+	for _, k := range keys {
+		rawKeys = append(rawKeys, k.Key)
+	}
+	return rawKeys
+}
+
+func (s *AccountService) initializeEncryptionKeys(ctx context.Context, tx TX, user *User, password string) (keys []*UserEncryptionKey, decrypted []byte, err error) {
+	// Create new keys
+	pub, priv, err := userenckey.GenerateKey()
+	if err != nil {
+		return nil, nil, err
+	}
+	decrypted = priv
+	enc, err := userenckey.Encrypt(priv, []byte(password))
+	if err != nil {
+		return nil, nil, err
+	}
+	keys = append(keys, &UserEncryptionKey{
+		ID:  UserEncryptionKeyMainID,
+		Key: enc,
+	})
+
+	// Save the new public key.
+	if err = tx.SetUserEncryptionPublicKey(ctx, user, pub); err != nil {
+		err = newBackendError(err)
+	}
+	return
+}
+
+func (s *AccountService) readOrInitializeEncryptionKeys(ctx context.Context, tx TX, user *User, oldPassword, newPassword string) (keys []*UserEncryptionKey, decrypted []byte, err error) {
+	if user.HasEncryptionKeys {
+		// Fetch the encryption keys from the database.
+		keys, err = tx.GetUserEncryptionKeys(ctx, user)
+		if err != nil {
+			return
+		}
+		decrypted, err = userenckey.Decrypt(keysToBytes(keys), []byte(oldPassword))
+		return
+	}
+	return s.initializeEncryptionKeys(ctx, tx, user, newPassword)
+}
+
+func updateEncryptionKey(keys []*UserEncryptionKey, decrypted []byte, keyID, password string) ([]*UserEncryptionKey, error) {
+	encrypted, err := userenckey.Encrypt(decrypted, []byte(password))
+	if err != nil {
+		return nil, err
+	}
+
+	// Replace the key with id 'keyID' in the in-memory list.
+	var keysOut []*UserEncryptionKey
+	for _, key := range keys {
+		if key.ID != keyID {
+			keysOut = append(keysOut, key)
+		}
+	}
+	keysOut = append(keysOut, &UserEncryptionKey{
+		ID:  keyID,
+		Key: encrypted,
+	})
+	return keysOut, nil
+}
diff --git a/errors.go b/errors.go
index b74704bca3f6094fce13e0f483c35a2ab35d42b8..3f7788eef4821b30d7d24ea7c0e577451463fce1 100644
--- a/errors.go
+++ b/errors.go
@@ -1,5 +1,18 @@
 package accountserver
 
+import "errors"
+
+var (
+	// ErrUnauthorized means that the request failed due to lack of authorization.
+	ErrUnauthorized = errors.New("unauthorized")
+
+	// ErrUserNotFound is returned when a user object is not found.
+	ErrUserNotFound = errors.New("user not found")
+
+	// ErrResourceNotFound is returned when a resource object is not found.
+	ErrResourceNotFound = errors.New("resource not found")
+)
+
 // It is important to distinguish between different classes of errors,
 // so that they can be translated into distinct HTTP status codes and
 // transmitted back to the client. Since we also want to retain the
@@ -14,6 +27,8 @@ func newAuthError(err error) error {
 	return &authError{err}
 }
 
+// IsAuthError returns true if err is an authentication /
+// authorization error.
 func IsAuthError(err error) bool {
 	_, ok := err.(*authError)
 	return ok
@@ -27,6 +42,8 @@ func newRequestError(err error) error {
 	return &requestError{err}
 }
 
+// IsRequestError returns true if err is a request error (bad
+// request).
 func IsRequestError(err error) bool {
 	_, ok := err.(*requestError)
 	return ok
@@ -40,6 +57,7 @@ func newBackendError(err error) error {
 	return &backendError{err}
 }
 
+// IsBackendError returns true if err is a backend error.
 func IsBackendError(err error) bool {
 	_, ok := err.(*backendError)
 	return ok
diff --git a/integrationtest/integration_test.go b/integrationtest/integration_test.go
new file mode 100644
index 0000000000000000000000000000000000000000..039b7d3890bd32f20a779b19d95f467cb0ec7952
--- /dev/null
+++ b/integrationtest/integration_test.go
@@ -0,0 +1,394 @@
+package integrationtest
+
+import (
+	"bytes"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"io/ioutil"
+	"log"
+	"net/http"
+	"net/http/httptest"
+	"os"
+	"testing"
+	"time"
+
+	"git.autistici.org/ai3/accountserver"
+	"git.autistici.org/ai3/accountserver/backend"
+	"git.autistici.org/ai3/accountserver/ldaptest"
+	"git.autistici.org/ai3/accountserver/server"
+	sso "git.autistici.org/id/go-sso"
+	"golang.org/x/crypto/ed25519"
+)
+
+const (
+	testLDAPPort = 42872
+	testLDAPAddr = "ldap://127.0.0.1:42872"
+
+	testSSODomain  = "domain"
+	testSSOService = "accountserver.domain/"
+	testAdminUser  = "admin"
+	testAdminGroup = "admins"
+)
+
+func withSSO(t testing.TB) (func(), sso.Signer, string) {
+	tmpf, err := ioutil.TempFile("", "")
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	pub, priv, err := ed25519.GenerateKey(nil)
+	if err != nil {
+		t.Fatal(err)
+	}
+	tmpf.Write(pub)
+	tmpf.Close()
+
+	signer, err := sso.NewSigner(priv)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	return func() {
+		os.Remove(tmpf.Name())
+	}, signer, tmpf.Name()
+}
+
+type testClient struct {
+	srvURL string
+	signer sso.Signer
+}
+
+func (c *testClient) ssoTicket(username string) string {
+	var groups []string
+	if username == testAdminUser {
+		groups = append(groups, testAdminGroup)
+	}
+	signed, err := c.signer.Sign(sso.NewTicket(username, testSSOService, testSSODomain, "", groups, 1*time.Hour))
+	if err != nil {
+		panic(err)
+	}
+	return signed
+}
+
+func (c *testClient) request(uri string, req, out interface{}) error {
+	data, _ := json.Marshal(req)
+	resp, err := http.Post(c.srvURL+uri, "application/json", bytes.NewReader(data))
+	if err != nil {
+		return err
+	}
+	defer resp.Body.Close()
+	data, _ = ioutil.ReadAll(resp.Body)
+
+	if resp.StatusCode >= 400 && resp.StatusCode < 500 {
+		log.Printf("request error: %s", string(data))
+		return errors.New(string(data))
+	}
+	if resp.StatusCode != 200 {
+		log.Printf("remote error: %s", string(data))
+		return fmt.Errorf("http status code %d", resp.StatusCode)
+	}
+	if resp.Header.Get("Content-Type") != "application/json" {
+		return fmt.Errorf("unexpected content-type %s", resp.Header.Get("Content-Type"))
+	}
+
+	log.Printf("response:\n%s\n", string(data))
+
+	if out == nil {
+		return nil
+	}
+	return json.Unmarshal(data, out)
+}
+
+func startService(t testing.TB) (func(), *testClient) {
+	stop := ldaptest.StartServer(t, &ldaptest.Config{
+		Dir:   "../ldaptest",
+		Port:  testLDAPPort,
+		Base:  "dc=example,dc=com",
+		LDIFs: []string{"testdata/base.ldif", "testdata/test1.ldif"},
+	})
+
+	be, err := backend.NewLDAPBackend(testLDAPAddr, "cn=manager,dc=example,dc=com", "password", "dc=example,dc=com")
+	if err != nil {
+		t.Fatal("NewLDAPBackend", err)
+	}
+
+	ssoStop, signer, ssoPubKeyFile := withSSO(t)
+
+	var svcConfig accountserver.Config
+	svcConfig.SSO.PublicKeyFile = ssoPubKeyFile
+	svcConfig.SSO.Domain = testSSODomain
+	svcConfig.SSO.Service = testSSOService
+	svcConfig.SSO.AdminGroup = testAdminGroup
+	svcConfig.AvailableDomains = map[string][]string{
+		accountserver.ResourceTypeEmail: []string{"example.com"},
+	}
+	shards := []string{"host1", "host2", "host3"}
+	svcConfig.Shards.Available = map[string][]string{
+		accountserver.ResourceTypeEmail:    shards,
+		accountserver.ResourceTypeWebsite:  shards,
+		accountserver.ResourceTypeDomain:   shards,
+		accountserver.ResourceTypeDAV:      shards,
+		accountserver.ResourceTypeDatabase: shards,
+	}
+	svcConfig.Shards.Allowed = svcConfig.Shards.Available
+	svcConfig.WebsiteRootDir = "/home/users/investici.org"
+
+	service, err := accountserver.NewAccountService(be, &svcConfig)
+	if err != nil {
+		stop()
+		t.Fatal("NewAccountService", err)
+	}
+
+	as := server.New(service, be)
+	srv := httptest.NewServer(as.Handler())
+
+	c := &testClient{
+		srvURL: srv.URL,
+		signer: signer,
+	}
+
+	return func() {
+		stop()
+		srv.Close()
+		ssoStop()
+	}, c
+}
+
+// Verify that authentication on GetUser works as expected:
+// - users can fetch their own data but not other users'
+// - admins can read everything.
+func TestIntegration_GetUser_Auth(t *testing.T) {
+	stop, c := startService(t)
+	defer stop()
+
+	testdata := []struct {
+		authUser   string
+		expectedOk bool
+	}{
+		{"uno@investici.org", true},
+		{"due@investici.org", false},
+		{testAdminUser, true},
+	}
+
+	for _, td := range testdata {
+		var user accountserver.User
+		err := c.request("/api/user/get", &accountserver.GetUserRequest{
+			RequestBase: accountserver.RequestBase{
+				Username: "uno@investici.org",
+				SSO:      c.ssoTicket(td.authUser),
+			},
+		}, &user)
+		if td.expectedOk && err != nil {
+			t.Errorf("access error for user %s: expected ok, got error: %v", td.authUser, err)
+		} else if !td.expectedOk && err == nil {
+			t.Errorf("access error for user %s: expected error, got ok", td.authUser)
+		}
+	}
+}
+
+// Verify that a user can change their password.
+func TestIntegration_ChangeUserPassword(t *testing.T) {
+	stop, c := startService(t)
+	defer stop()
+
+	err := c.request("/api/user/change_password", &accountserver.ChangeUserPasswordRequest{
+		PrivilegedRequestBase: accountserver.PrivilegedRequestBase{
+			RequestBase: accountserver.RequestBase{
+				Username: "uno@investici.org",
+				SSO:      c.ssoTicket("uno@investici.org"),
+			},
+			// Since the user in the test fixture does
+			// *not* have encryption key, and
+			// authenticateWithPassword is currently a
+			// no-op, this test will pass.
+			CurPassword: "ha ha, really not the current password",
+		},
+		Password: "new_password",
+	}, nil)
+
+	if err != nil {
+		t.Fatal("ChangePassword", err)
+	}
+}
+
+// Verify that changing the password sets user encryption keys.
+func TestIntegration_ChangeUserPassword_SetsEncryptionKeys(t *testing.T) {
+	stop, c := startService(t)
+	defer stop()
+
+	testdata := []struct {
+		password    string
+		newPassword string
+		expectedOk  bool
+	}{
+		// Ordering is important as it is meant to emulate
+		// setting the password, failing to reset it, then
+		// succeeding.
+		{"password", "new_password", true},
+		{"BADPASS", "new_password_2", false},
+		{"new_password", "new_password_2", true},
+	}
+	for _, td := range testdata {
+		err := c.request("/api/user/change_password", &accountserver.ChangeUserPasswordRequest{
+			PrivilegedRequestBase: accountserver.PrivilegedRequestBase{
+				RequestBase: accountserver.RequestBase{
+					Username: "uno@investici.org",
+					SSO:      c.ssoTicket("uno@investici.org"),
+				},
+				CurPassword: td.password,
+			},
+			Password: td.newPassword,
+		}, nil)
+		if err == nil && !td.expectedOk {
+			t.Fatalf("ChangeUserPassword(old=%s new=%s) should have failed but didn't", td.password, td.newPassword)
+		} else if err != nil && td.expectedOk {
+			t.Fatalf("ChangeUserPassword(old=%s new=%s) failed: %v", td.password, td.newPassword, err)
+		}
+	}
+}
+
+func TestIntegration_CreateResource(t *testing.T) {
+	stop, c := startService(t)
+	defer stop()
+
+	testdata := []struct {
+		resource   *accountserver.Resource
+		expectedOk bool
+	}{
+		// Create a domain resource.
+		{
+			&accountserver.Resource{
+				ID:            accountserver.NewResourceID(accountserver.ResourceTypeDomain, "uno@investici.org", "example2.com"),
+				Name:          "example2.com",
+				Status:        accountserver.ResourceStatusActive,
+				Shard:         "host2",
+				OriginalShard: "host2",
+				Website: &accountserver.Website{
+					URL:          "https://example2.com",
+					DocumentRoot: "/home/users/investici.org/uno/html-example2.com",
+					AcceptMail:   true,
+				},
+			},
+			true,
+		},
+
+		// Duplicate of the above request, should fail due to conflict.
+		{
+			&accountserver.Resource{
+				ID:            accountserver.NewResourceID(accountserver.ResourceTypeDomain, "uno@investici.org", "example2.com"),
+				Name:          "example2.com",
+				Status:        accountserver.ResourceStatusActive,
+				Shard:         "host2",
+				OriginalShard: "host2",
+				Website: &accountserver.Website{
+					URL:          "https://example2.com",
+					DocumentRoot: "/home/users/investici.org/uno/html-example2.com",
+				},
+			},
+			false,
+		},
+
+		// Empty document root will be fixed by templating.
+		{
+			&accountserver.Resource{
+				ID:            accountserver.NewResourceID(accountserver.ResourceTypeDomain, "uno@investici.org", "example3.com"),
+				Name:          "example3.com",
+				Status:        accountserver.ResourceStatusActive,
+				Shard:         "host2",
+				OriginalShard: "host2",
+				Website:       &accountserver.Website{},
+			},
+			true,
+		},
+
+		// Malformed resource metadata (name fails validation).
+		{
+			&accountserver.Resource{
+				ID:            accountserver.NewResourceID(accountserver.ResourceTypeDomain, "uno@investici.org", "example$.com"),
+				Name:          "example$.com",
+				Status:        accountserver.ResourceStatusActive,
+				Shard:         "host2",
+				OriginalShard: "host2",
+				Website: &accountserver.Website{
+					URL:          "https://example$.com",
+					DocumentRoot: "/home/users/investici.org/uno/html-example3.com",
+				},
+			},
+			false,
+		},
+
+		// Bad shard.
+		{
+			&accountserver.Resource{
+				ID:            accountserver.NewResourceID(accountserver.ResourceTypeDomain, "uno@investici.org", "example3.com"),
+				Name:          "example4.com",
+				Status:        accountserver.ResourceStatusActive,
+				Shard:         "zebra",
+				OriginalShard: "zebra",
+				Website: &accountserver.Website{
+					URL:          "https://example4.com",
+					DocumentRoot: "/home/users/investici.org/uno/html-example4.com",
+				},
+			},
+			false,
+		},
+
+		// The document root has no associated DAV account.
+		{
+			&accountserver.Resource{
+				ID:            accountserver.NewResourceID(accountserver.ResourceTypeDomain, "uno@investici.org", "example3.com"),
+				Name:          "example5.com",
+				Status:        accountserver.ResourceStatusActive,
+				Shard:         "host2",
+				OriginalShard: "host2",
+				Website: &accountserver.Website{
+					URL:          "https://example5.com",
+					DocumentRoot: "/home/users/investici.org/nonexisting",
+				},
+			},
+			false,
+		},
+	}
+
+	for _, td := range testdata {
+		err := c.request("/api/resource/create", &accountserver.CreateResourcesRequest{
+			SSO:       c.ssoTicket(testAdminUser),
+			Resources: []*accountserver.Resource{td.resource},
+		}, nil)
+		if err == nil && !td.expectedOk {
+			t.Errorf("CreateResource(%s) should have failed but didn't", td.resource.ID)
+		} else if err != nil && td.expectedOk {
+			t.Errorf("CreateResource(%s) failed: %v", td.resource.ID, err)
+		}
+	}
+}
+
+func TestIntegration_CreateMultipleResources_WithTemplate(t *testing.T) {
+	stop, c := startService(t)
+	defer stop()
+
+	// The create request is very bare, most values will be filled
+	// in by the server using resource templates.
+	err := c.request("/api/resource/create", &accountserver.CreateResourcesRequest{
+		SSO: c.ssoTicket(testAdminUser),
+		Resources: []*accountserver.Resource{
+			&accountserver.Resource{
+				ID:   accountserver.NewResourceID(accountserver.ResourceTypeDomain, "uno@investici.org", "example3.com"),
+				Name: "example3.com",
+			},
+			&accountserver.Resource{
+				ID:   accountserver.NewResourceID(accountserver.ResourceTypeDAV, "uno@investici.org", "example3dav"),
+				Name: "example3dav",
+			},
+			&accountserver.Resource{
+				ID:       accountserver.NewResourceID(accountserver.ResourceTypeDatabase, "uno@investici.org", "cn=example3.com", "example3"),
+				ParentID: accountserver.NewResourceID(accountserver.ResourceTypeDomain, "uno@investici.org", "example3.com"),
+				Name:     "example3",
+			},
+		},
+	}, nil)
+	if err != nil {
+		t.Errorf("CreateResources failed: %v", err)
+	}
+}
diff --git a/integrationtest/testdata/base.ldif b/integrationtest/testdata/base.ldif
new file mode 100644
index 0000000000000000000000000000000000000000..fcbc2849fa34661c06ccd104a91052e2fa326dde
--- /dev/null
+++ b/integrationtest/testdata/base.ldif
@@ -0,0 +1,16 @@
+
+dn: dc=example,dc=com
+objectclass: domain
+objectclass: top
+dc: example
+
+dn: ou=People,dc=example,dc=com
+objectclass: top
+objectclass: organizationalUnit
+ou: People
+
+dn: ou=Lists,dc=example,dc=com
+objectclass: top
+objectclass: organizationalUnit
+ou: Lists
+
diff --git a/integrationtest/testdata/test1.ldif b/integrationtest/testdata/test1.ldif
new file mode 100644
index 0000000000000000000000000000000000000000..9bf6880683b247b165f7b48ccd62866470c24af8
--- /dev/null
+++ b/integrationtest/testdata/test1.ldif
@@ -0,0 +1,106 @@
+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: cn=example.com,uid=uno@investici.org,ou=People,dc=example,dc=com
+status: active
+acceptMail: true
+objectClass: top
+objectClass: virtualHost
+cn: example.com
+host: host2
+documentRoot: /home/users/investici.org/uno/html-example.com
+creationDate: 02-08-2013
+originalHost: host2
+statsId: 2192
+
+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/ldaptest/ldap_server.go b/ldaptest/ldap_server.go
new file mode 100644
index 0000000000000000000000000000000000000000..62682bac9dca0de0a9fc354d7fa739be618339c6
--- /dev/null
+++ b/ldaptest/ldap_server.go
@@ -0,0 +1,69 @@
+package ldaptest
+
+import (
+	"errors"
+	"fmt"
+	"io/ioutil"
+	"net"
+	"os"
+	"os/exec"
+	"path/filepath"
+	"testing"
+	"time"
+)
+
+// Config for the test in-memory LDAP server.
+type Config struct {
+	Dir   string
+	Base  string
+	Port  int
+	LDIFs []string
+}
+
+func waitForPort(port int, timeout time.Duration) error {
+	addr := fmt.Sprintf("127.0.0.1:%d", port)
+	deadline := time.Now().Add(timeout)
+	for {
+		conn, err := net.Dial("tcp", addr)
+		if err == nil {
+			conn.Close()
+			return nil
+		}
+		time.Sleep(200 * time.Millisecond)
+		if time.Now().After(deadline) {
+			return errors.New("server did not come up within the deadline")
+		}
+	}
+}
+
+// StartServer starts a test LDAP server with the specified configuration.
+func StartServer(t testing.TB, config *Config) func() {
+	tmpf, err := ioutil.TempFile("", "")
+	if err != nil {
+		t.Fatal(err)
+	}
+	fmt.Fprintf(tmpf, `ldap.rootDn=%s
+ldap.managerDn=cn=manager,%s
+ldap.managerPassword=password
+ldap.port=%d
+`, config.Base, config.Base, config.Port)
+	defer tmpf.Close()
+
+	args := []string{tmpf.Name()}
+	args = append(args, config.LDIFs...)
+	proc := exec.Command(filepath.Join(config.Dir, "unboundid-ldap-server/bin/unboundid-ldap-server"), args...)
+	proc.Stdout = os.Stdout
+	proc.Stderr = os.Stderr
+
+	if err := proc.Start(); err != nil {
+		t.Fatalf("error starting LDAP server: %v", err)
+	}
+
+	waitForPort(config.Port, 5*time.Second)
+
+	return func() {
+		proc.Process.Kill()
+		proc.Wait()
+		os.Remove(tmpf.Name())
+	}
+}
diff --git a/backend/unboundid-ldap-server/bin/unboundid-ldap-server b/ldaptest/unboundid-ldap-server/bin/unboundid-ldap-server
similarity index 87%
rename from backend/unboundid-ldap-server/bin/unboundid-ldap-server
rename to ldaptest/unboundid-ldap-server/bin/unboundid-ldap-server
index a2140c60608eed0862e15c83d08b9a2793fcf781..fd117bfba6926b62da88e027eeac8240a96b9464 100755
--- a/backend/unboundid-ldap-server/bin/unboundid-ldap-server
+++ b/ldaptest/unboundid-ldap-server/bin/unboundid-ldap-server
@@ -1,4 +1,4 @@
-#!/usr/bin/env bash
+#!/usr/bin/env sh
 
 ##############################################################################
 ##
@@ -6,12 +6,30 @@
 ##
 ##############################################################################
 
-# Add default JVM options here. You can also use JAVA_OPTS and UNBOUNDID_LDAP_SERVER_OPTS to pass JVM options to this script.
-DEFAULT_JVM_OPTS=""
+# Attempt to set APP_HOME
+# Resolve links: $0 may be a link
+PRG="$0"
+# Need this for relative symlinks.
+while [ -h "$PRG" ] ; do
+    ls=`ls -ld "$PRG"`
+    link=`expr "$ls" : '.*-> \(.*\)$'`
+    if expr "$link" : '/.*' > /dev/null; then
+        PRG="$link"
+    else
+        PRG=`dirname "$PRG"`"/$link"
+    fi
+done
+SAVED="`pwd`"
+cd "`dirname \"$PRG\"`/.." >/dev/null
+APP_HOME="`pwd -P`"
+cd "$SAVED" >/dev/null
 
 APP_NAME="unboundid-ldap-server"
 APP_BASE_NAME=`basename "$0"`
 
+# Add default JVM options here. You can also use JAVA_OPTS and UNBOUNDID_LDAP_SERVER_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS=""
+
 # Use the maximum available, or set MAX_FD != -1 to use that value.
 MAX_FD="maximum"
 
@@ -30,6 +48,7 @@ die ( ) {
 cygwin=false
 msys=false
 darwin=false
+nonstop=false
 case "`uname`" in
   CYGWIN* )
     cygwin=true
@@ -40,26 +59,11 @@ case "`uname`" in
   MINGW* )
     msys=true
     ;;
+  NONSTOP* )
+    nonstop=true
+    ;;
 esac
 
-# Attempt to set APP_HOME
-# Resolve links: $0 may be a link
-PRG="$0"
-# Need this for relative symlinks.
-while [ -h "$PRG" ] ; do
-    ls=`ls -ld "$PRG"`
-    link=`expr "$ls" : '.*-> \(.*\)$'`
-    if expr "$link" : '/.*' > /dev/null; then
-        PRG="$link"
-    else
-        PRG=`dirname "$PRG"`"/$link"
-    fi
-done
-SAVED="`pwd`"
-cd "`dirname \"$PRG\"`/.." >/dev/null
-APP_HOME="`pwd -P`"
-cd "$SAVED" >/dev/null
-
 CLASSPATH=$APP_HOME/lib/unboundid-ldap-server.jar:$APP_HOME/lib/slf4j-api-1.7.12.jar:$APP_HOME/lib/unboundid-ldapsdk-3.1.1.jar:$APP_HOME/lib/ldaptive-unboundid-1.1.0.jar:$APP_HOME/lib/ldaptive-beans-1.1.0.jar:$APP_HOME/lib/commons-io-2.4.jar:$APP_HOME/lib/spring-core-4.2.1.RELEASE.jar:$APP_HOME/lib/log4j-api-2.3.jar:$APP_HOME/lib/log4j-core-2.3.jar:$APP_HOME/lib/log4j-slf4j-impl-2.3.jar:$APP_HOME/lib/log4j-jcl-2.3.jar:$APP_HOME/lib/ldaptive-1.1.0.jar:$APP_HOME/lib/commons-logging-1.2.jar:$APP_HOME/lib/commons-cli-1.3.1.jar
 
 # Determine the Java command to use to start the JVM.
@@ -85,7 +89,7 @@ location of your Java installation."
 fi
 
 # Increase the maximum file descriptors if we can.
-if [ "$cygwin" = "false" -a "$darwin" = "false" ] ; then
+if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
     MAX_FD_LIMIT=`ulimit -H -n`
     if [ $? -eq 0 ] ; then
         if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
@@ -150,11 +154,19 @@ if $cygwin ; then
     esac
 fi
 
-# Split up the JVM_OPTS And UNBOUNDID_LDAP_SERVER_OPTS values into an array, following the shell quoting and substitution rules
-function splitJvmOpts() {
-    JVM_OPTS=("$@")
+# Escape application args
+save ( ) {
+    for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
+    echo " "
 }
-eval splitJvmOpts $DEFAULT_JVM_OPTS $JAVA_OPTS $UNBOUNDID_LDAP_SERVER_OPTS
+APP_ARGS=$(save "$@")
 
+# Collect all arguments for the java command, following the shell quoting and substitution rules
+eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $UNBOUNDID_LDAP_SERVER_OPTS -classpath "\"$CLASSPATH\"" com.unboundid.ldap.LdapServer "$APP_ARGS"
+
+# by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong
+if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then
+  cd "$(dirname "$0")"
+fi
 
-exec "$JAVACMD" "${JVM_OPTS[@]}" -classpath "$CLASSPATH" com.unboundid.ldap.LdapServer "$@"
+exec "$JAVACMD" "$@"
diff --git a/backend/unboundid-ldap-server/bin/unboundid-ldap-server.bat b/ldaptest/unboundid-ldap-server/bin/unboundid-ldap-server.bat
similarity index 90%
rename from backend/unboundid-ldap-server/bin/unboundid-ldap-server.bat
rename to ldaptest/unboundid-ldap-server/bin/unboundid-ldap-server.bat
index 1c93278b4fb0759f55e8b28604105528fb1f3145..accd36e0de0832e4502d15b13bf9d6c823daaadb 100755
--- a/backend/unboundid-ldap-server/bin/unboundid-ldap-server.bat
+++ b/ldaptest/unboundid-ldap-server/bin/unboundid-ldap-server.bat
@@ -8,14 +8,14 @@
 @rem Set local scope for the variables with windows NT shell
 if "%OS%"=="Windows_NT" setlocal
 
-@rem Add default JVM options here. You can also use JAVA_OPTS and UNBOUNDID_LDAP_SERVER_OPTS to pass JVM options to this script.
-set DEFAULT_JVM_OPTS=
-
 set DIRNAME=%~dp0
 if "%DIRNAME%" == "" set DIRNAME=.
 set APP_BASE_NAME=%~n0
 set APP_HOME=%DIRNAME%..
 
+@rem Add default JVM options here. You can also use JAVA_OPTS and UNBOUNDID_LDAP_SERVER_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS=
+
 @rem Find java.exe
 if defined JAVA_HOME goto findJavaFromJavaHome
 
@@ -46,10 +46,9 @@ echo location of your Java installation.
 goto fail
 
 :init
-@rem Get command-line arguments, handling Windowz variants
+@rem Get command-line arguments, handling Windows variants
 
 if not "%OS%" == "Windows_NT" goto win9xME_args
-if "%@eval[2+2]" == "4" goto 4NT_args
 
 :win9xME_args
 @rem Slurp the command line arguments.
@@ -60,11 +59,6 @@ set _SKIP=2
 if "x%~1" == "x" goto execute
 
 set CMD_LINE_ARGS=%*
-goto execute
-
-:4NT_args
-@rem Get arguments from the 4NT Shell from JP Software
-set CMD_LINE_ARGS=%$
 
 :execute
 @rem Setup the command line
diff --git a/backend/unboundid-ldap-server/lib/commons-cli-1.3.1.jar b/ldaptest/unboundid-ldap-server/lib/commons-cli-1.3.1.jar
similarity index 100%
rename from backend/unboundid-ldap-server/lib/commons-cli-1.3.1.jar
rename to ldaptest/unboundid-ldap-server/lib/commons-cli-1.3.1.jar
diff --git a/backend/unboundid-ldap-server/lib/commons-io-2.4.jar b/ldaptest/unboundid-ldap-server/lib/commons-io-2.4.jar
similarity index 100%
rename from backend/unboundid-ldap-server/lib/commons-io-2.4.jar
rename to ldaptest/unboundid-ldap-server/lib/commons-io-2.4.jar
diff --git a/backend/unboundid-ldap-server/lib/commons-logging-1.2.jar b/ldaptest/unboundid-ldap-server/lib/commons-logging-1.2.jar
similarity index 100%
rename from backend/unboundid-ldap-server/lib/commons-logging-1.2.jar
rename to ldaptest/unboundid-ldap-server/lib/commons-logging-1.2.jar
diff --git a/backend/unboundid-ldap-server/lib/ldaptive-1.1.0.jar b/ldaptest/unboundid-ldap-server/lib/ldaptive-1.1.0.jar
similarity index 100%
rename from backend/unboundid-ldap-server/lib/ldaptive-1.1.0.jar
rename to ldaptest/unboundid-ldap-server/lib/ldaptive-1.1.0.jar
diff --git a/backend/unboundid-ldap-server/lib/ldaptive-beans-1.1.0.jar b/ldaptest/unboundid-ldap-server/lib/ldaptive-beans-1.1.0.jar
similarity index 100%
rename from backend/unboundid-ldap-server/lib/ldaptive-beans-1.1.0.jar
rename to ldaptest/unboundid-ldap-server/lib/ldaptive-beans-1.1.0.jar
diff --git a/backend/unboundid-ldap-server/lib/ldaptive-unboundid-1.1.0.jar b/ldaptest/unboundid-ldap-server/lib/ldaptive-unboundid-1.1.0.jar
similarity index 100%
rename from backend/unboundid-ldap-server/lib/ldaptive-unboundid-1.1.0.jar
rename to ldaptest/unboundid-ldap-server/lib/ldaptive-unboundid-1.1.0.jar
diff --git a/backend/unboundid-ldap-server/lib/log4j-api-2.3.jar b/ldaptest/unboundid-ldap-server/lib/log4j-api-2.3.jar
similarity index 100%
rename from backend/unboundid-ldap-server/lib/log4j-api-2.3.jar
rename to ldaptest/unboundid-ldap-server/lib/log4j-api-2.3.jar
diff --git a/backend/unboundid-ldap-server/lib/log4j-core-2.3.jar b/ldaptest/unboundid-ldap-server/lib/log4j-core-2.3.jar
similarity index 100%
rename from backend/unboundid-ldap-server/lib/log4j-core-2.3.jar
rename to ldaptest/unboundid-ldap-server/lib/log4j-core-2.3.jar
diff --git a/backend/unboundid-ldap-server/lib/log4j-jcl-2.3.jar b/ldaptest/unboundid-ldap-server/lib/log4j-jcl-2.3.jar
similarity index 100%
rename from backend/unboundid-ldap-server/lib/log4j-jcl-2.3.jar
rename to ldaptest/unboundid-ldap-server/lib/log4j-jcl-2.3.jar
diff --git a/backend/unboundid-ldap-server/lib/log4j-slf4j-impl-2.3.jar b/ldaptest/unboundid-ldap-server/lib/log4j-slf4j-impl-2.3.jar
similarity index 100%
rename from backend/unboundid-ldap-server/lib/log4j-slf4j-impl-2.3.jar
rename to ldaptest/unboundid-ldap-server/lib/log4j-slf4j-impl-2.3.jar
diff --git a/backend/unboundid-ldap-server/lib/slf4j-api-1.7.12.jar b/ldaptest/unboundid-ldap-server/lib/slf4j-api-1.7.12.jar
similarity index 100%
rename from backend/unboundid-ldap-server/lib/slf4j-api-1.7.12.jar
rename to ldaptest/unboundid-ldap-server/lib/slf4j-api-1.7.12.jar
diff --git a/backend/unboundid-ldap-server/lib/spring-core-4.2.1.RELEASE.jar b/ldaptest/unboundid-ldap-server/lib/spring-core-4.2.1.RELEASE.jar
similarity index 100%
rename from backend/unboundid-ldap-server/lib/spring-core-4.2.1.RELEASE.jar
rename to ldaptest/unboundid-ldap-server/lib/spring-core-4.2.1.RELEASE.jar
diff --git a/backend/unboundid-ldap-server/lib/unboundid-ldap-server.jar b/ldaptest/unboundid-ldap-server/lib/unboundid-ldap-server.jar
similarity index 92%
rename from backend/unboundid-ldap-server/lib/unboundid-ldap-server.jar
rename to ldaptest/unboundid-ldap-server/lib/unboundid-ldap-server.jar
index 65162f51e5636cc8a040f154af044e94e3387c88..366895a50293c4523ba41705a046275c65d43d1e 100644
Binary files a/backend/unboundid-ldap-server/lib/unboundid-ldap-server.jar and b/ldaptest/unboundid-ldap-server/lib/unboundid-ldap-server.jar differ
diff --git a/backend/unboundid-ldap-server/lib/unboundid-ldapsdk-3.1.1.jar b/ldaptest/unboundid-ldap-server/lib/unboundid-ldapsdk-3.1.1.jar
similarity index 100%
rename from backend/unboundid-ldap-server/lib/unboundid-ldapsdk-3.1.1.jar
rename to ldaptest/unboundid-ldap-server/lib/unboundid-ldapsdk-3.1.1.jar
diff --git a/server/server.go b/server/server.go
index de4df64512c01fb04da6dac900a0a7bd47271527..2b9a46926ed8787b13caf7bd60ffdedf6cf07e11 100644
--- a/server/server.go
+++ b/server/server.go
@@ -1,6 +1,9 @@
 package server
 
 import (
+	"context"
+	"encoding/json"
+	"errors"
 	"log"
 	"net/http"
 
@@ -9,194 +12,177 @@ import (
 	as "git.autistici.org/ai3/accountserver"
 )
 
+type txHandler func(as.TX, http.ResponseWriter, *http.Request) (interface{}, error)
+
+var errBadRequest = errors.New("bad request")
+
 type AccountServer struct {
 	service *as.AccountService
+	backend as.Backend
 }
 
-func New(service *as.AccountService) *AccountServer {
-	return &AccountServer{service}
-}
-
-var emptyResponse = map[string]string{}
-
-func errToStatus(err error) int {
-	switch {
-	case err == as.ErrUserNotFound, err == as.ErrResourceNotFound:
-		return http.StatusNotFound
-	case as.IsAuthError(err):
-		return http.StatusUnauthorized
-	case as.IsRequestError(err):
-		return http.StatusBadRequest
-	default:
-		return http.StatusInternalServerError
+func New(service *as.AccountService, backend as.Backend) *AccountServer {
+	return &AccountServer{
+		service: service,
+		backend: backend,
 	}
 }
 
-func (s *AccountServer) handleGetUser(w http.ResponseWriter, r *http.Request) {
-	var req as.GetUserRequest
-	if !serverutil.DecodeJSONRequest(w, r, &req) {
-		return
-	}
+var emptyResponse struct{}
 
-	user, err := s.service.GetUser(r.Context(), &req)
-	if err != nil {
-		log.Printf("GetUser(%s): error: %v", req.Username, err)
-		http.Error(w, err.Error(), errToStatus(err))
-		return
-	}
-
-	serverutil.EncodeJSONResponse(w, user)
+func (s *AccountServer) handleGetUser(tx as.TX, w http.ResponseWriter, r *http.Request) (interface{}, error) {
+	var req as.GetUserRequest
+	return handleJSON(w, r, &req, func(ctx context.Context) (interface{}, error) {
+		return s.service.GetUser(ctx, tx, &req)
+	})
 }
 
-func (s *AccountServer) handleChangeUserPassword(w http.ResponseWriter, r *http.Request) {
+func (s *AccountServer) handleChangeUserPassword(tx as.TX, w http.ResponseWriter, r *http.Request) (interface{}, error) {
 	var req as.ChangeUserPasswordRequest
-	if !serverutil.DecodeJSONRequest(w, r, &req) {
-		return
-	}
+	return handleJSON(w, r, &req, func(ctx context.Context) (interface{}, error) {
+		return &emptyResponse, s.service.ChangeUserPassword(ctx, tx, &req)
+	})
+}
 
-	if err := s.service.ChangeUserPassword(r.Context(), &req); err != nil {
-		log.Printf("ChangeUserPassword(%s): error: %v", req.Username, err)
-		http.Error(w, err.Error(), errToStatus(err))
-		return
-	}
+func (s *AccountServer) handleSetPasswordRecoveryHint(tx as.TX, w http.ResponseWriter, r *http.Request) (interface{}, error) {
+	var req as.SetPasswordRecoveryHintRequest
+	return handleJSON(w, r, &req, func(ctx context.Context) (interface{}, error) {
+		return &emptyResponse, s.service.SetPasswordRecoveryHint(ctx, tx, &req)
+	})
+}
 
-	serverutil.EncodeJSONResponse(w, emptyResponse)
+func (s *AccountServer) handleRecoverPassword(tx as.TX, w http.ResponseWriter, r *http.Request) (interface{}, error) {
+	var req as.PasswordRecoveryRequest
+	return handleJSON(w, r, &req, func(ctx context.Context) (interface{}, error) {
+		return &emptyResponse, s.service.RecoverPassword(ctx, tx, &req)
+	})
 }
 
-func (s *AccountServer) handleCreateApplicationSpecificPassword(w http.ResponseWriter, r *http.Request) {
+func (s *AccountServer) handleCreateApplicationSpecificPassword(tx as.TX, w http.ResponseWriter, r *http.Request) (interface{}, error) {
 	var req as.CreateApplicationSpecificPasswordRequest
-	if !serverutil.DecodeJSONRequest(w, r, &req) {
-		return
-	}
-
-	resp, err := s.service.CreateApplicationSpecificPassword(r.Context(), &req)
-	if err != nil {
-		log.Printf("CreateApplicationSpecificPassword(%s): error: %v", req.Username, err)
-		http.Error(w, err.Error(), errToStatus(err))
-		return
-	}
-
-	serverutil.EncodeJSONResponse(w, resp)
+	return handleJSON(w, r, &req, func(ctx context.Context) (interface{}, error) {
+		return s.service.CreateApplicationSpecificPassword(ctx, tx, &req)
+	})
 }
 
-func (s *AccountServer) handleDeleteApplicationSpecificPassword(w http.ResponseWriter, r *http.Request) {
+func (s *AccountServer) handleDeleteApplicationSpecificPassword(tx as.TX, w http.ResponseWriter, r *http.Request) (interface{}, error) {
 	var req as.DeleteApplicationSpecificPasswordRequest
-	if !serverutil.DecodeJSONRequest(w, r, &req) {
-		return
-	}
-
-	if err := s.service.DeleteApplicationSpecificPassword(r.Context(), &req); err != nil {
-		log.Printf("DeleteApplicationSpecificPassword(%s): error: %v", req.Username, err)
-		http.Error(w, err.Error(), errToStatus(err))
-		return
-	}
-
-	serverutil.EncodeJSONResponse(w, emptyResponse)
+	return handleJSON(w, r, &req, func(ctx context.Context) (interface{}, error) {
+		return &emptyResponse, s.service.DeleteApplicationSpecificPassword(ctx, tx, &req)
+	})
 }
 
-func (s *AccountServer) handleEnableResource(w http.ResponseWriter, r *http.Request) {
+func (s *AccountServer) handleEnableResource(tx as.TX, w http.ResponseWriter, r *http.Request) (interface{}, error) {
 	var req as.EnableResourceRequest
-	if !serverutil.DecodeJSONRequest(w, r, &req) {
-		return
-	}
-
-	if err := s.service.EnableResource(r.Context(), &req); err != nil {
-		log.Printf("EnableResource(%s): error: %v", req.Username, err)
-		http.Error(w, err.Error(), errToStatus(err))
-		return
-	}
-
-	serverutil.EncodeJSONResponse(w, emptyResponse)
+	return handleJSON(w, r, &req, func(ctx context.Context) (interface{}, error) {
+		return &emptyResponse, s.service.EnableResource(ctx, tx, &req)
+	})
 }
 
-func (s *AccountServer) handleDisableResource(w http.ResponseWriter, r *http.Request) {
+func (s *AccountServer) handleDisableResource(tx as.TX, w http.ResponseWriter, r *http.Request) (interface{}, error) {
 	var req as.DisableResourceRequest
-	if !serverutil.DecodeJSONRequest(w, r, &req) {
-		return
-	}
-
-	if err := s.service.DisableResource(r.Context(), &req); err != nil {
-		log.Printf("DisableResource(%s): error: %v", req.Username, err)
-		http.Error(w, err.Error(), errToStatus(err))
-		return
-	}
-
-	serverutil.EncodeJSONResponse(w, emptyResponse)
+	return handleJSON(w, r, &req, func(ctx context.Context) (interface{}, error) {
+		return &emptyResponse, s.service.DisableResource(ctx, tx, &req)
+	})
 }
 
-func (s *AccountServer) handleChangeResourcePassword(w http.ResponseWriter, r *http.Request) {
-	var req as.ChangeResourcePasswordRequest
-	if !serverutil.DecodeJSONRequest(w, r, &req) {
-		return
-	}
-
-	if err := s.service.ChangeResourcePassword(r.Context(), &req); err != nil {
-		log.Printf("ChangeResourcePassword(%s): error: %v", req.Username, err)
-		http.Error(w, err.Error(), errToStatus(err))
-		return
-	}
-
-	serverutil.EncodeJSONResponse(w, emptyResponse)
+func (s *AccountServer) handleCreateResources(tx as.TX, w http.ResponseWriter, r *http.Request) (interface{}, error) {
+	var req as.CreateResourcesRequest
+	return handleJSON(w, r, &req, func(ctx context.Context) (interface{}, error) {
+		return s.service.CreateResources(ctx, tx, &req)
+	})
 }
 
-func (s *AccountServer) handleMoveResource(w http.ResponseWriter, r *http.Request) {
+func (s *AccountServer) handleMoveResource(tx as.TX, w http.ResponseWriter, r *http.Request) (interface{}, error) {
 	var req as.MoveResourceRequest
-	if !serverutil.DecodeJSONRequest(w, r, &req) {
-		return
-	}
-
-	resp, err := s.service.MoveResource(r.Context(), &req)
-	if err != nil {
-		log.Printf("MoveResource(%s): error: %v", req.Username, err)
-		http.Error(w, err.Error(), errToStatus(err))
-		return
-	}
-
-	serverutil.EncodeJSONResponse(w, resp)
+	return handleJSON(w, r, &req, func(ctx context.Context) (interface{}, error) {
+		return s.service.MoveResource(ctx, tx, &req)
+	})
 }
 
-func (s *AccountServer) handleEnableOTP(w http.ResponseWriter, r *http.Request) {
+func (s *AccountServer) handleEnableOTP(tx as.TX, w http.ResponseWriter, r *http.Request) (interface{}, error) {
 	var req as.EnableOTPRequest
-	if !serverutil.DecodeJSONRequest(w, r, &req) {
-		return
-	}
+	return handleJSON(w, r, &req, func(ctx context.Context) (interface{}, error) {
+		return s.service.EnableOTP(ctx, tx, &req)
+	})
+}
 
-	resp, err := s.service.EnableOTP(r.Context(), &req)
-	if err != nil {
-		log.Printf("EnableOTP(%s): error: %v", req.Username, err)
-		http.Error(w, err.Error(), errToStatus(err))
-		return
+func (s *AccountServer) handleDisableOTP(tx as.TX, w http.ResponseWriter, r *http.Request) (interface{}, error) {
+	var req as.DisableOTPRequest
+	return handleJSON(w, r, &req, func(ctx context.Context) (interface{}, error) {
+		return &emptyResponse, s.service.DisableOTP(ctx, tx, &req)
+	})
+}
+
+func (s *AccountServer) withTx(f txHandler) http.HandlerFunc {
+	return func(w http.ResponseWriter, r *http.Request) {
+		tx, err := s.backend.NewTransaction()
+		if err != nil {
+			log.Printf("NewTransaction error: %v", err)
+			http.Error(w, err.Error(), http.StatusInternalServerError)
+			return
+		}
+
+		resp, err := f(tx, w, r)
+		if err != nil {
+			return
+		}
+		// Automatically commit the transaction (if
+		// the handler didn't do this itself).
+		if err := tx.Commit(r.Context()); err != nil {
+			log.Printf("Commit error: %v", err)
+			http.Error(w, err.Error(), http.StatusInternalServerError)
+			return
+		}
+
+		serverutil.EncodeJSONResponse(w, resp)
 	}
+}
 
-	serverutil.EncodeJSONResponse(w, resp)
+func (s *AccountServer) Handler() http.Handler {
+	h := http.NewServeMux()
+	h.HandleFunc("/api/user/get", s.withTx(s.handleGetUser))
+	h.HandleFunc("/api/user/change_password", s.withTx(s.handleChangeUserPassword))
+	h.HandleFunc("/api/user/set_password_recovery_hint", s.withTx(s.handleSetPasswordRecoveryHint))
+	h.HandleFunc("/api/user/enable_otp", s.withTx(s.handleEnableOTP))
+	h.HandleFunc("/api/user/disable_otp", s.withTx(s.handleDisableOTP))
+	h.HandleFunc("/api/user/create_app_specific_password", s.withTx(s.handleCreateApplicationSpecificPassword))
+	h.HandleFunc("/api/user/delete_app_specific_password", s.withTx(s.handleDeleteApplicationSpecificPassword))
+	h.HandleFunc("/api/resource/enable", s.withTx(s.handleEnableResource))
+	h.HandleFunc("/api/resource/disable", s.withTx(s.handleDisableResource))
+	h.HandleFunc("/api/resource/create", s.withTx(s.handleCreateResources))
+	h.HandleFunc("/api/resource/move", s.withTx(s.handleMoveResource))
+	h.HandleFunc("/api/recover_password", s.withTx(s.handleRecoverPassword))
+	return h
 }
 
-func (s *AccountServer) handleDisableOTP(w http.ResponseWriter, r *http.Request) {
-	var req as.DisableOTPRequest
-	if !serverutil.DecodeJSONRequest(w, r, &req) {
-		return
+func errToStatus(err error) int {
+	switch {
+	case err == as.ErrUserNotFound, err == as.ErrResourceNotFound:
+		return http.StatusNotFound
+	case as.IsAuthError(err):
+		return http.StatusUnauthorized
+	case as.IsRequestError(err):
+		return http.StatusBadRequest
+	default:
+		return http.StatusInternalServerError
 	}
+}
 
-	if err := s.service.DisableOTP(r.Context(), &req); err != nil {
-		log.Printf("DisableOTP(%s): error: %v", req.Username, err)
+func handleJSON(w http.ResponseWriter, r *http.Request, req interface{}, f func(context.Context) (interface{}, error)) (interface{}, error) {
+	if !serverutil.DecodeJSONRequest(w, r, req) {
+		return nil, errBadRequest
+	}
+	resp, err := f(r.Context())
+	if err != nil {
+		log.Printf("error in %s: %v, request=%s", r.URL.Path, err, dumpRequest(req))
 		http.Error(w, err.Error(), errToStatus(err))
-		return
+		return nil, err
 	}
-
-	serverutil.EncodeJSONResponse(w, emptyResponse)
+	return resp, nil
 }
 
-func (s *AccountServer) Handler() http.Handler {
-	h := http.NewServeMux()
-	h.HandleFunc("/api/user/get", s.handleGetUser)
-	h.HandleFunc("/api/user/change_password", s.handleChangeUserPassword)
-	h.HandleFunc("/api/user/enable_otp", s.handleEnableOTP)
-	h.HandleFunc("/api/user/disable_otp", s.handleDisableOTP)
-	h.HandleFunc("/api/app_specific_password/create", s.handleCreateApplicationSpecificPassword)
-	h.HandleFunc("/api/app_specific_password/delete", s.handleDeleteApplicationSpecificPassword)
-	h.HandleFunc("/api/resource/enable", s.handleEnableResource)
-	h.HandleFunc("/api/resource/disable", s.handleDisableResource)
-	h.HandleFunc("/api/resource/change_password", s.handleChangeResourcePassword)
-	h.HandleFunc("/api/resource/move", s.handleMoveResource)
-	return h
+func dumpRequest(req interface{}) string {
+	data, _ := json.Marshal(req)
+	return string(data)
 }
diff --git a/service.go b/service.go
new file mode 100644
index 0000000000000000000000000000000000000000..bfb8c10fa3b486a93185bb004b4f7f37ee797a4b
--- /dev/null
+++ b/service.go
@@ -0,0 +1,409 @@
+package accountserver
+
+import (
+	"context"
+	"encoding/json"
+	"log"
+	"reflect"
+
+	"git.autistici.org/id/go-sso"
+)
+
+// Backend user database interface.
+//
+// We are using a transactional interface even if the actual backend
+// (LDAP) does not support atomic transactions, just so it is easy to
+// add more backends in the future (like SQL).
+type Backend interface {
+	NewTransaction() (TX, error)
+}
+
+// TX represents a single transaction with the backend and offers a
+// high-level data management abstraction.
+//
+// All methods share similar semantics: Get methods will return nil if
+// the requested object is not found, and only return an error in case
+// of trouble reaching the backend itself.
+//
+// The backend enforces strict public/private data separation by
+// having Get methods return public objects (as defined in types.go),
+// and using specialized methods to modify the private
+// (authentication-related) attributes.
+//
+// We might add more sophisticated resource query methods later, as
+// admin-level functionality.
+//
+type TX interface {
+	Commit(context.Context) error
+
+	GetResource(context.Context, ResourceID) (*Resource, error)
+	UpdateResource(context.Context, *Resource) error
+	CreateResource(context.Context, *Resource) error
+	SetResourcePassword(context.Context, *Resource, string) error
+	HasAnyResource(context.Context, []FindResourceRequest) (bool, error)
+
+	GetUser(context.Context, string) (*User, error)
+	CreateUser(context.Context, *User) error
+	SetUserPassword(context.Context, *User, string) error
+	SetPasswordRecoveryHint(context.Context, *User, string, string) error
+	GetUserEncryptionKeys(context.Context, *User) ([]*UserEncryptionKey, error)
+	SetUserEncryptionKeys(context.Context, *User, []*UserEncryptionKey) error
+	SetUserEncryptionPublicKey(context.Context, *User, []byte) error
+	SetApplicationSpecificPassword(context.Context, *User, *AppSpecificPasswordInfo, string) error
+	DeleteApplicationSpecificPassword(context.Context, *User, string) error
+	SetUserTOTPSecret(context.Context, *User, string) error
+	DeleteUserTOTPSecret(context.Context, *User) 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
+// functionality of the user accounts management service.
+type AccountService struct {
+	*authService
+
+	audit auditLogger
+
+	fieldValidators   *fieldValidators
+	resourceValidator *resourceValidator
+	userValidator     UserValidatorFunc
+	resourceTemplates *templateContext
+}
+
+// NewAccountService builds a new AccountService with the specified configuration.
+func NewAccountService(backend Backend, config *Config) (*AccountService, error) {
+	ssoValidator, err := config.ssoValidator()
+	if err != nil {
+		return nil, err
+	}
+
+	return newAccountServiceWithSSO(backend, config, ssoValidator)
+}
+
+func newAccountServiceWithSSO(backend Backend, config *Config, ssoValidator sso.Validator) (*AccountService, error) {
+	s := &AccountService{
+		authService: newAuthService(config, ssoValidator),
+		audit:       &syslogAuditLogger{},
+	}
+
+	vc, err := config.validationContext(backend)
+	if err != nil {
+		return nil, err
+	}
+	s.fieldValidators = newFieldValidators(vc)
+	s.resourceValidator = newResourceValidator(vc)
+	s.userValidator = vc.validUser()
+
+	s.resourceTemplates = config.templateContext()
+
+	return s, nil
+}
+
+type authService struct {
+	validator     sso.Validator
+	ssoService    string
+	ssoGroups     []string
+	ssoAdminGroup string
+}
+
+func newAuthService(config *Config, v sso.Validator) *authService {
+	return &authService{
+		validator:     v,
+		ssoService:    config.SSO.Service,
+		ssoGroups:     config.SSO.Groups,
+		ssoAdminGroup: config.SSO.AdminGroup,
+	}
+}
+
+func (s *authService) isAdmin(tkt *sso.Ticket) bool {
+	for _, g := range tkt.Groups {
+		if g == s.ssoAdminGroup {
+			return true
+		}
+	}
+	return false
+}
+
+func (s *authService) validateSSO(ssoToken string) (*sso.Ticket, error) {
+	return s.validator.Validate(ssoToken, "", s.ssoService, s.ssoGroups)
+}
+
+func getUserOrDie(ctx context.Context, tx TX, username string) (*User, error) {
+	user, err := tx.GetUser(ctx, username)
+	if err != nil {
+		return nil, newBackendError(err)
+	}
+	if user == nil {
+		return nil, ErrUserNotFound
+	}
+	return user, nil
+}
+
+func getResourceOrDie(ctx context.Context, tx TX, id ResourceID) (*Resource, error) {
+	r, err := tx.GetResource(ctx, id)
+	if err != nil {
+		return nil, newBackendError(err)
+	}
+	if r == nil {
+		return nil, ErrResourceNotFound
+	}
+	return r, nil
+}
+
+type authUserCtxKeyType int
+
+var authUserCtxKey authUserCtxKeyType
+
+func authUserFromContext(ctx context.Context) string {
+	s, ok := ctx.Value(userCtxKey).(string)
+	if ok {
+		return s
+	}
+	return ""
+}
+
+func (s *authService) authorizeAdminGeneric(ctx context.Context, tx TX, ssoTicket string) (context.Context, error) {
+	// Validate the SSO ticket.
+	tkt, err := s.validateSSO(ssoTicket)
+	if err != nil {
+		return nil, newAuthError(err)
+	}
+
+	// Requests are allowed if the SSO ticket corresponds to an admin, or if
+	// it identifies the same user that we're querying.
+	if !s.isAdmin(tkt) {
+		return nil, newAuthError(ErrUnauthorized)
+	}
+
+	ctx = context.WithValue(ctx, authUserCtxKey, tkt.User)
+	return ctx, nil
+}
+
+func (s *authService) authorizeAdmin(ctx context.Context, tx TX, req RequestBase) (context.Context, *User, error) {
+	// Validate the SSO ticket.
+	tkt, err := s.validateSSO(req.SSO)
+	if err != nil {
+		return nil, nil, newAuthError(err)
+	}
+
+	// Requests are allowed if the SSO ticket corresponds to an admin, or if
+	// it identifies the same user that we're querying.
+	if !s.isAdmin(tkt) {
+		return nil, nil, newAuthError(ErrUnauthorized)
+	}
+
+	ctx = context.WithValue(ctx, authUserCtxKey, tkt.User)
+	user, err := getUserOrDie(ctx, tx, req.Username)
+	return ctx, user, err
+}
+
+func (s *authService) authorizeUser(ctx context.Context, tx TX, req RequestBase) (context.Context, *User, error) {
+	// First, check that the username matches the SSO ticket
+	// username (or that the SSO ticket has admin permissions).
+	tkt, err := s.validateSSO(req.SSO)
+	if err != nil {
+		return nil, nil, newAuthError(err)
+	}
+
+	// Requests are allowed if the SSO ticket corresponds to an admin, or if
+	// it identifies the same user that we're querying.
+	if !s.isAdmin(tkt) && tkt.User != req.Username {
+		return nil, nil, newAuthError(ErrUnauthorized)
+	}
+
+	user, err := getUserOrDie(ctx, tx, req.Username)
+	ctx = context.WithValue(ctx, authUserCtxKey, tkt.User)
+	return ctx, user, err
+}
+
+// Extended version of authorizeUser that also directly checks the
+// user password. Used for account-privileged operations related to
+// credential manipulation.
+func (s *authService) authorizeUserWithPassword(ctx context.Context, tx TX, req PrivilegedRequestBase) (context.Context, *User, error) {
+	// TODO: call out to the auth-server?
+	return s.authorizeUser(ctx, tx, req.RequestBase)
+}
+
+// Extended version of authorizeUser that checks access to a specific
+// resource (which must belong to the specified user). To be used
+// wherever we take a ResourceID in the request.
+//
+// Note that this access control method only works for resources that
+// have explicit ownership (i.e. they have the user in their resource
+// ID). For shared resources like mailing lists, we will need to
+// delegate the ownership check to the Resource itself.
+func (s *authService) authorizeResource(ctx context.Context, tx TX, req ResourceRequestBase) (context.Context, *Resource, error) {
+	tkt, err := s.validateSSO(req.SSO)
+	if err != nil {
+		return nil, nil, newAuthError(err)
+	}
+
+	r, err := getResourceOrDie(ctx, tx, req.ResourceID)
+	if err != nil {
+		return nil, nil, err
+	}
+
+	if !s.isAdmin(tkt) && !canAccessResource(tkt.User, r) {
+		return nil, nil, newAuthError(ErrUnauthorized)
+	}
+
+	ctx = context.WithValue(ctx, authUserCtxKey, tkt.User)
+	return ctx, r, nil
+}
+
+func canAccessResource(username string, r *Resource) bool {
+	switch r.ID.Type() {
+	case ResourceTypeMailingList:
+		// Check the list owners.
+		for _, a := range r.List.Admins {
+			if a == username {
+				return true
+			}
+		}
+		return false
+	default:
+		return r.ID.User() == username
+	}
+}
+
+type authHandlerFunc func(context.Context, TX) (context.Context, *User, *Resource, error)
+
+func (s *authService) authResource(reqBase ResourceRequestBase) authHandlerFunc {
+	return func(ctx context.Context, tx TX) (context.Context, *User, *Resource, error) {
+		ctx, resource, err := s.authorizeResource(ctx, tx, reqBase)
+		return ctx, nil, resource, err
+	}
+}
+
+func (s *authService) authUser(reqBase RequestBase) authHandlerFunc {
+	return func(ctx context.Context, tx TX) (context.Context, *User, *Resource, error) {
+		ctx, user, err := s.authorizeUser(ctx, tx, reqBase)
+		return ctx, user, nil, err
+	}
+}
+
+func (s *authService) authUserWithPassword(reqBase PrivilegedRequestBase) authHandlerFunc {
+	return func(ctx context.Context, tx TX) (context.Context, *User, *Resource, error) {
+		ctx, user, err := s.authorizeUserWithPassword(ctx, tx, reqBase)
+		return ctx, user, nil, err
+	}
+}
+
+func (s *authService) authAdmin(reqBase RequestBase) authHandlerFunc {
+	return func(ctx context.Context, tx TX) (context.Context, *User, *Resource, error) {
+		ctx, user, err := s.authorizeAdmin(ctx, tx, reqBase)
+		return ctx, user, nil, err
+	}
+}
+
+type hasNewContext interface {
+	NewContext(context.Context) context.Context
+}
+
+type hasApplyTemplate interface {
+	ApplyTemplate(context.Context, *AccountService, *User)
+}
+
+type hasValidate interface {
+	Validate(context.Context, *AccountService) error
+}
+
+type hasCompoundValidate interface {
+	Validate(context.Context, *AccountService, *User) error
+}
+
+type hasValidationUser interface {
+	ValidationUser(context.Context, TX) *User
+}
+
+// Wrapper for actions that validates requests and sets up some
+// request-related parameters (mostly in the Context, used for later
+// logging). The user parameter, if present, is passed to the Validate
+// request method.
+func (s *AccountService) withRequest(ctx context.Context, tx TX, req interface{}, user *User, f func(context.Context) error) error {
+	// If the request has a NewContext() method, call it to obtain
+	// a request-specific context (this step usually adds
+	// parameters for logging).
+	if rnc, ok := req.(hasNewContext); ok {
+		ctx = rnc.NewContext(ctx)
+	}
+
+	// Fetch the user to be passed to Validate() from the request
+	// if one was not provided as an argument.
+	if user == nil {
+		if rvu, ok := req.(hasValidationUser); ok {
+			user = rvu.ValidationUser(ctx, tx)
+		}
+	}
+
+	// Apply a template to the request, to fill in default values
+	// etc., if the request has an ApplyTemplate() method.
+	if rt, ok := req.(hasApplyTemplate); ok {
+		rt.ApplyTemplate(ctx, s, user)
+	}
+
+	// If the request has a Validate() method, validate the
+	// request. We support two different fingerprints for the
+	// Validate() method, one without, and the other with a *User
+	// argument ("compound Validate"), for resource-level
+	// validators.
+	if rv, ok := req.(hasValidate); ok {
+		if err := rv.Validate(ctx, s); err != nil {
+			return newRequestError(err)
+		}
+	} else if cv, ok := req.(hasCompoundValidate); ok {
+		if err := cv.Validate(ctx, s, user); err != nil {
+			return newRequestError(err)
+		}
+	}
+
+	err := f(ctx)
+	log.Printf("%s %s err=%v", reflect.TypeOf(req).String(), dumpRequest(req), err)
+	return err
+}
+
+// Handle a user request. Authorize, validate, and run the given function on the User object.
+func (s *AccountService) handleUserRequest(ctx context.Context, tx TX, req interface{}, auth authHandlerFunc, f func(context.Context, *User) error) (err error) {
+	var user *User
+	ctx, user, _, err = auth(ctx, tx)
+	if err != nil {
+		return
+	}
+
+	return s.withRequest(ctx, tx, req, user, func(ctx context.Context) error {
+		return f(ctx, user)
+	})
+}
+
+// Handle a resource request. Authorize, validate, and run the given function on the Resource object.
+func (s *AccountService) handleResourceRequest(ctx context.Context, tx TX, req interface{}, auth authHandlerFunc, f func(context.Context, *Resource) error) (err error) {
+	var user *User
+	var resource *Resource
+	ctx, user, resource, err = auth(ctx, tx)
+	if err != nil {
+		return
+	}
+
+	return s.withRequest(ctx, tx, req, user, func(ctx context.Context) error {
+		return f(ctx, resource)
+	})
+}
+
+// Handle an admin request (i.e. a request with no explicit associated account or resource).
+func (s *AccountService) handleAdminRequest(ctx context.Context, tx TX, req interface{}, ssoTicket string, f func(context.Context) error) (err error) {
+	ctx, err = s.authService.authorizeAdminGeneric(ctx, tx, ssoTicket)
+	if err != nil {
+		return
+	}
+
+	return s.withRequest(ctx, tx, req, nil, f)
+}
+
+func dumpRequest(req interface{}) string {
+	data, _ := json.Marshal(req)
+	return string(data)
+}
diff --git a/types.go b/types.go
index bbd6cb32c59ecb275c27ee0ba6e3f31689d3ae18..447f32e23da5710ca682d1957e7834c99f390cae 100644
--- a/types.go
+++ b/types.go
@@ -1,7 +1,10 @@
 package accountserver
 
 import (
-	"fmt"
+	"encoding/json"
+	"errors"
+	"net/url"
+	"path/filepath"
 	"strings"
 	"time"
 )
@@ -29,29 +32,42 @@ type User struct {
 	AppSpecificPasswords []*AppSpecificPasswordInfo `json:"app_specific_passwords,omitempty"`
 
 	Resources []*Resource `json:"resources,omitempty"`
+}
 
-	Opaque interface{}
+// GetResourceByID returns the resource with the specified ID, or nil
+// if not found.
+func (u *User) GetResourceByID(id ResourceID) *Resource {
+	for _, r := range u.Resources {
+		if r.ID.Equal(id) {
+			return r
+		}
+	}
+	return nil
 }
 
+// GetResourcesByType returns all resources with the specified type.
 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)
 		}
 	}
 	return out
 }
 
+// GetSingleResourceByType returns a single resource of the specified
+// type. If there are none, returns nil.
 func (u *User) GetSingleResourceByType(resourceType string) *Resource {
 	for _, r := range u.Resources {
-		if r.Type == resourceType {
+		if r.ID.Type() == resourceType {
 			return r
 		}
 	}
 	return nil
 }
 
+// GetResourcesByGroup returns all resources belonging to the specified group.
 func (u *User) GetResourcesByGroup(group string) []*Resource {
 	var out []*Resource
 	for _, r := range u.Resources {
@@ -70,6 +86,8 @@ type AppSpecificPasswordInfo struct {
 	Comment string `json:"comment"`
 }
 
+// Well-known user encryption key types, corresponding to primary and
+// secondary passwords.
 const (
 	UserEncryptionKeyMainID     = "main"
 	UserEncryptionKeyRecoveryID = "recovery"
@@ -82,60 +100,143 @@ type UserEncryptionKey struct {
 	Key []byte `json:"key"`
 }
 
-func DecodeUserEncryptionKeys(values []string) []*UserEncryptionKey {
-	var out []*UserEncryptionKey
-	for _, value := range values {
-		idx := strings.IndexByte(value, ':')
-		if idx < 0 {
-			continue
-		}
-		out = append(out, &UserEncryptionKey{
-			ID:  value[:idx],
-			Key: []byte(value[idx+1:]),
-		})
-	}
-	return out
-}
-
-func EncodeUserEncryptionKeys(keys []*UserEncryptionKey) []string {
-	var out []string
-	for _, key := range keys {
-		out = append(out, fmt.Sprintf("%s:%s", key.ID, string(key.Key)))
-	}
-	return out
-}
-
+// Resource types.
 const (
 	ResourceTypeEmail       = "email"
 	ResourceTypeMailingList = "list"
 	ResourceTypeWebsite     = "web"
+	ResourceTypeDomain      = "domain"
 	ResourceTypeDAV         = "dav"
 	ResourceTypeDatabase    = "db"
 )
 
+// Resource status values.
 const (
 	ResourceStatusActive   = "active"
 	ResourceStatusInactive = "inactive"
+	ResourceStatusReadonly = "readonly"
 )
 
+// ResourceID is a 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).
+type ResourceID struct {
+	Parts []string
+}
+
+// NewResourceID builds a ResourceID out of a list of path components.
+func NewResourceID(p ...string) ResourceID {
+	return ResourceID{Parts: p}
+}
+
+// Equal returns true if the two IDs are the same.
+func (i ResourceID) Equal(other ResourceID) bool {
+	if len(i.Parts) != len(other.Parts) {
+		return false
+	}
+	for idx := 0; idx < len(i.Parts); idx++ {
+		if i.Parts[idx] != other.Parts[idx] {
+			return false
+		}
+	}
+	return true
+}
+
+// Empty returns true if the ResourceID has the nil value.
+func (i ResourceID) Empty() bool {
+	return len(i.Parts) == 0
+}
+
+// Type of the resource, the first component.
+func (i ResourceID) Type() string {
+	return i.Parts[0]
+}
+
+// Name of the resource, without path. This is the last component of
+// the composite ID.
+func (i ResourceID) Name() string {
+	return i.Parts[len(i.Parts)-1]
+}
+
+// User that owns the resource. This may be missing for some resources
+// (those that share a single global namespace), in which case this
+// function will return an empty string.
+func (i ResourceID) User() string {
+	if len(i.Parts) > 2 {
+		return i.Parts[1]
+	}
+	return ""
+}
+
+// Path for the resource, i.e. the ID components after type and
+// (optionally) user.
+func (i ResourceID) Path() []string {
+	if len(i.Parts) > 2 {
+		return i.Parts[2:]
+	}
+	return i.Parts[1:]
+}
+
+func (i ResourceID) String() string {
+	var tmp []string
+	for _, s := range i.Parts {
+		tmp = append(tmp, url.PathEscape(s))
+	}
+	return filepath.Join(tmp...)
+}
+
+// MarshalJSON serializes a resource ID to JSON.
+func (i ResourceID) MarshalJSON() ([]byte, error) {
+	return json.Marshal(i.String())
+}
+
+// UnmarshalJSON deserializes a resource ID from JSON.
+func (i *ResourceID) UnmarshalJSON(data []byte) error {
+	var s string
+	err := json.Unmarshal(data, &s)
+	if err != nil {
+		return err
+	}
+	if s != "" {
+		*i, err = ParseResourceID(s)
+	}
+	return err
+}
+
+// ParseResourceID parses a string representation of a ResourceID.
+func ParseResourceID(s string) (ResourceID, error) {
+	var id ResourceID
+	for _, e := range strings.Split(s, "/") {
+		u, err := url.PathUnescape(e)
+		if err != nil {
+			return ResourceID{}, err
+		}
+		id.Parts = append(id.Parts, u)
+	}
+	if len(id.Parts) < 2 {
+		return ResourceID{}, errors.New("malformed resource ID")
+	}
+	return id, nil
+}
+
 // Resource represents a somewhat arbitrary resource, identified by a
 // unique name/type combination (a.k.a. its ID). A resource contains
 // 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 string `json:"id"`
-
-	// Name of the resource, unique to the resource type namespace
-	// for this user.
+	// 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 string `json:"parent_id,omitempty"`
+	ParentID ResourceID `json:"parent_id,omitempty"`
 
 	// Optional attribute for resources that have a status.
 	Status string `json:"status,omitempty"`
@@ -157,11 +258,6 @@ 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{} `json:"-"`
 }
 
 // Copy the resource (makes a deep copy).
@@ -208,10 +304,10 @@ type WebDAV struct {
 
 // Website resource attributes.
 type Website struct {
-	URL          string            `json:"url"`
-	DisplayName  string            `json:"display_name"`
+	URL          string            `json:"url,omitempty"`
+	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"`
diff --git a/validators.go b/validators.go
index 224b8d2a4b8ac201e15e00768365acf65641866b..e128da69dd1f401d8a6646a37b6d2627f09c4ada 100644
--- a/validators.go
+++ b/validators.go
@@ -5,7 +5,10 @@ import (
 	"context"
 	"errors"
 	"fmt"
+	"log"
+	"math/rand"
 	"os"
+	"path/filepath"
 	"regexp"
 	"strings"
 
@@ -14,17 +17,31 @@ import (
 
 // A domainBackend manages the list of domains users are allowed to request services on.
 type domainBackend interface {
-	GetAvailableDomains(context.Context, string) []string
-	IsAvailableDomain(context.Context, string, string) bool
+	GetAllowedDomains(context.Context, string) []string
+	IsAllowedDomain(context.Context, string, string) bool
 }
 
-// The checkBackend verifies if specific resources already exist or are available.
-type checkBackend interface {
-	HasAnyResource(context.Context, []string) (bool, error)
+// A shardBackend can return information about available / allowed service shards.
+type shardBackend interface {
+	GetAllowedShards(context.Context, string) []string
+	GetAvailableShards(context.Context, string) []string
+	IsAllowedShard(context.Context, string, string) bool
 }
 
-type validationConfig struct {
+// The validationContext contains all configuration and backends that
+// the various validation functions will need. Most methods on this
+// object return functions themselves (ValidatorFunc or variations
+// thereof) that can later be called multiple times at will and
+// combined with operators like 'allOf'.
+type validationContext struct {
 	forbiddenUsernames stringSet
+	forbiddenPasswords stringSet
+	minPasswordLength  int
+	maxPasswordLength  int
+	webroot            string
+	domains            domainBackend
+	shards             shardBackend
+	backend            Backend
 }
 
 // A stringSet is just a list of strings with a quick membership test.
@@ -41,6 +58,13 @@ func newStringSetFromList(list []string) stringSet {
 	return stringSet{set: set, list: list}
 }
 
+func newStringSetFromFileOrList(list []string, path string) (stringSet, error) {
+	if path != "" {
+		return loadStringSetFromFile(path)
+	}
+	return newStringSetFromList(list), nil
+}
+
 func (s stringSet) Contains(needle string) bool {
 	_, ok := s.set[needle]
 	return ok
@@ -55,14 +79,31 @@ type staticDomainBackend struct {
 	sets map[string]stringSet
 }
 
-func (d *staticDomainBackend) GetAvailableDomains(_ context.Context, kind string) []string {
+func (d *staticDomainBackend) GetAllowedDomains(_ context.Context, kind string) []string {
 	return d.sets[kind].List()
 }
 
-func (d *staticDomainBackend) IsAvailableDomain(_ context.Context, kind, domain string) bool {
+func (d *staticDomainBackend) IsAllowedDomain(_ context.Context, kind, domain string) bool {
 	return d.sets[kind].Contains(domain)
 }
 
+type staticShardBackend struct {
+	available map[string]stringSet
+	allowed   map[string]stringSet
+}
+
+func (d *staticShardBackend) GetAllowedShards(_ context.Context, kind string) []string {
+	return d.allowed[kind].List()
+}
+
+func (d *staticShardBackend) GetAvailableShards(_ context.Context, kind string) []string {
+	return d.available[kind].List()
+}
+
+func (d *staticShardBackend) IsAllowedShard(_ context.Context, kind, shard string) bool {
+	return d.allowed[kind].Contains(shard)
+}
+
 func loadStringSetFromFile(path string) (stringSet, error) {
 	f, err := os.Open(path)
 	if err != nil {
@@ -157,7 +198,7 @@ func isRegistered(domain string) bool {
 	return true
 }
 
-func validDomainName(value string) error {
+func validDomainName(_ context.Context, value string) error {
 	if !domainRx.MatchString(value) {
 		return errors.New("invalid domain name")
 	}
@@ -183,85 +224,127 @@ func splitEmailAddr(addr string) (string, string) {
 
 // Returns all the possible resources in the email and mailing list
 // namespaces that might conflict with the given email address.
-func relatedEmails(ctx context.Context, be domainBackend, addr string) []string {
-	resourceIDs := []string{fmt.Sprintf("%s/%s", ResourceTypeEmail, addr)}
+func relatedEmails(ctx context.Context, be domainBackend, addr string) []FindResourceRequest {
+	rel := []FindResourceRequest{
+		{Type: ResourceTypeEmail, Name: addr},
+	}
 	user, _ := splitEmailAddr(addr)
 	// Mailing lists must have unique names regardless of the domain, so we
 	// add potential conflicts for mailing lists with the same name over all
 	// list-enabled domains.
-	for _, d := range be.GetAvailableDomains(ctx, ResourceTypeMailingList) {
-		resourceIDs = append(resourceIDs, fmt.Sprintf("%s/%s@%s", ResourceTypeMailingList, user, d))
+	for _, d := range be.GetAllowedDomains(ctx, ResourceTypeMailingList) {
+		rel = append(rel, FindResourceRequest{
+			Type: ResourceTypeMailingList,
+			Name: fmt.Sprintf("%s@%s", user, d),
+		})
 	}
-	return resourceIDs
+	return rel
 }
 
-func splitSubsite(value string) (string, string) {
-	parts := strings.SplitN(value, "/", 2)
-	return parts[0], parts[1]
+func relatedWebsites(ctx context.Context, be domainBackend, value string) []FindResourceRequest {
+	// Ignore the parent domain (websites share a global namespace).
+	return []FindResourceRequest{
+		{
+			Type: ResourceTypeWebsite,
+			Name: value,
+		},
+	}
 }
 
-func isSubsite(value string) bool {
-	return strings.Contains(value, "/")
+func relatedDomains(ctx context.Context, be domainBackend, value string) []FindResourceRequest {
+	return []FindResourceRequest{
+		{
+			Type: ResourceTypeDomain,
+			Name: value,
+		},
+	}
 }
 
-func relatedWebsites(ctx context.Context, be domainBackend, value string) []string {
-	var resourceIDs []string
-	if isSubsite(value) {
-		_, path := splitSubsite(value)
-		for _, d := range be.GetAvailableDomains(ctx, ResourceTypeWebsite) {
-			resourceIDs = append(resourceIDs, fmt.Sprintf("%s/%s/%s", ResourceTypeWebsite, d, path))
+func (v *validationContext) isAllowedDomain(rtype string) ValidatorFunc {
+	return func(ctx context.Context, value string) error {
+		if !v.domains.IsAllowedDomain(ctx, rtype, value) {
+			return errors.New("unavailable domain")
 		}
-	} else {
-		resourceIDs = append(resourceIDs, fmt.Sprintf("%s/%s", ResourceTypeWebsite, value))
+		return nil
 	}
-	return resourceIDs
 }
 
-func isAvailableEmailHostingDomain(be domainBackend) ValidatorFunc {
+func (v *validationContext) isAvailableEmailAddr() ValidatorFunc {
 	return func(ctx context.Context, value string) error {
-		if !be.IsAvailableDomain(ctx, ResourceTypeEmail, value) {
-			return errors.New("unavailable domain")
+		rel := relatedEmails(ctx, v.domains, value)
+
+		// Run the presence check in a new transaction. Unavailability
+		// of the server results in a validation error (fail close).
+		tx, err := v.backend.NewTransaction()
+		if err != nil {
+			return err
+		}
+		if ok, _ := tx.HasAnyResource(ctx, rel); ok {
+			return errors.New("address unavailable")
 		}
 		return nil
 	}
 }
 
-func isAvailableMailingListDomain(be domainBackend) ValidatorFunc {
+func (v *validationContext) isAvailableDomain() ValidatorFunc {
 	return func(ctx context.Context, value string) error {
-		if !be.IsAvailableDomain(ctx, ResourceTypeMailingList, value) {
-			return errors.New("unavailable domain")
+		rel := relatedDomains(ctx, v.domains, value)
+
+		// Run the presence check in a new transaction. Unavailability
+		// of the server results in a validation error (fail close).
+		tx, err := v.backend.NewTransaction()
+		if err != nil {
+			return err
+		}
+		if ok, _ := tx.HasAnyResource(ctx, rel); ok {
+			return errors.New("address unavailable")
 		}
 		return nil
 	}
 }
 
-func isAvailableEmailAddr(be domainBackend, cb checkBackend) ValidatorFunc {
+func (v *validationContext) isAvailableWebsite() ValidatorFunc {
 	return func(ctx context.Context, value string) error {
-		rel := relatedEmails(ctx, be, value)
-		if ok, _ := cb.HasAnyResource(ctx, rel); ok {
+		rel := relatedWebsites(ctx, v.domains, value)
+
+		// Run the presence check in a new transaction. Unavailability
+		// of the server results in a validation error (fail close).
+		tx, err := v.backend.NewTransaction()
+		if err != nil {
+			return err
+		}
+		if ok, _ := tx.HasAnyResource(ctx, rel); ok {
 			return errors.New("address unavailable")
 		}
 		return nil
 	}
 }
 
-func validHostedEmail(config *validationConfig, be domainBackend, cb checkBackend) ValidatorFunc {
+func (v *validationContext) validHostedEmail() ValidatorFunc {
 	return allOf(
 		validateUsernameAndDomain(
-			allOf(matchUsernameRx(), minLength(4), notInSet(config.forbiddenUsernames)),
-			allOf(isAvailableEmailHostingDomain(be)),
+			allOf(matchUsernameRx(), minLength(4), maxLength(64), notInSet(v.forbiddenUsernames)),
+			allOf(v.isAllowedDomain(ResourceTypeEmail)),
 		),
-		isAvailableEmailAddr(be, cb),
+		v.isAvailableEmailAddr(),
 	)
 }
 
-func validHostedMailingList(config *validationConfig, be domainBackend, cb checkBackend) ValidatorFunc {
+func (v *validationContext) validHostedMailingList() ValidatorFunc {
 	return allOf(
 		validateUsernameAndDomain(
-			allOf(matchUsernameRx(), minLength(4), notInSet(config.forbiddenUsernames)),
-			allOf(isAvailableMailingListDomain(be)),
+			allOf(matchUsernameRx(), minLength(4), maxLength(64), notInSet(v.forbiddenUsernames)),
+			allOf(v.isAllowedDomain(ResourceTypeMailingList)),
 		),
-		isAvailableEmailAddr(be, cb),
+		v.isAvailableEmailAddr(),
+	)
+}
+
+func (v *validationContext) validPassword() ValidatorFunc {
+	return allOf(
+		minLength(v.minPasswordLength),
+		maxLength(v.maxPasswordLength),
+		notInSet(v.forbiddenPasswords),
 	)
 }
 
@@ -275,3 +358,423 @@ func allOf(funcs ...ValidatorFunc) ValidatorFunc {
 		return nil
 	}
 }
+
+// ResourceValidatorFunc is a composite type validator that checks
+// various fields in a Resource, depending on its type.
+type ResourceValidatorFunc func(context.Context, *Resource, *User) error
+
+func (v *validationContext) validateResource(_ context.Context, r *Resource, user *User) error {
+	// Resource name must match the name in the resource ID
+	// (until we get rid of the Name field).
+	if r.Name != r.ID.Name() {
+		return errors.New("mismatched ID and name")
+	}
+
+	// Validate the status enum.
+	switch r.Status {
+	case ResourceStatusActive, ResourceStatusInactive, ResourceStatusReadonly:
+	default:
+		return errors.New("unknown resource status")
+	}
+
+	// Ensure that, if the resource has an user, it is the given user.
+	u := r.ID.User()
+	switch {
+	case u == "" && user != nil:
+		return fmt.Errorf("attempt to modify global resource in user context (user=%s)", user.Name)
+	case u != "" && user == nil:
+		return errors.New("resource ID has user but no user in context")
+	case user != nil && u != user.Name:
+		return errors.New("can't modify resource owned by another user")
+	}
+
+	// If the resource has a ParentID, it must reference another
+	// resource owned by the user.
+	if !r.ParentID.Empty() {
+		if user == nil {
+			return errors.New("resource can't have parent without user context")
+		}
+		if p := user.GetResourceByID(r.ParentID); p == nil {
+			return errors.New("parent references unknown resource")
+		}
+	}
+
+	return nil
+}
+
+func (v *validationContext) validateShardedResource(ctx context.Context, r *Resource, user *User) error {
+	if err := v.validateResource(ctx, r, user); err != nil {
+		return err
+	}
+	if !v.shards.IsAllowedShard(ctx, r.ID.Type(), r.Shard) {
+		return fmt.Errorf(
+			"invalid shard %s for resource type %s (allowed: %v)",
+			r.Shard,
+			r.ID.Type(),
+			v.shards.GetAllowedShards(ctx, r.ID.Type()),
+		)
+	}
+	if r.OriginalShard == "" {
+		return errors.New("empty original_shard")
+	}
+	return nil
+}
+
+func (v *validationContext) validEmailResource() ResourceValidatorFunc {
+	emailValidator := v.validHostedEmail()
+
+	return func(ctx context.Context, r *Resource, user *User) error {
+		if err := v.validateShardedResource(ctx, r, user); err != nil {
+			return err
+		}
+
+		// Email resources aren't nested.
+		if !r.ParentID.Empty() {
+			return errors.New("resource should not have parent")
+		}
+
+		if r.Email == nil {
+			return errors.New("resource has no email metadata")
+		}
+		if err := emailValidator(ctx, r.ID.Name()); err != nil {
+			return err
+		}
+		if r.Email.Maildir == "" {
+			return errors.New("empty maildir")
+		}
+		return nil
+	}
+}
+
+func (v *validationContext) validListResource() ResourceValidatorFunc {
+	listValidator := v.validHostedMailingList()
+
+	return func(ctx context.Context, r *Resource, user *User) error {
+		if err := v.validateShardedResource(ctx, r, user); err != nil {
+			return err
+		}
+		if r.List == nil {
+			return errors.New("resource has no list metadata")
+		}
+		if err := listValidator(ctx, r.ID.Name()); err != nil {
+			return err
+		}
+		if len(r.List.Admins) < 1 {
+			return errors.New("can't create a list without admins")
+		}
+		return nil
+	}
+}
+
+func findMatchingDAVAccount(user *User, r *Resource) *Resource {
+	for _, dav := range user.GetResourcesByType(ResourceTypeDAV) {
+		if isSubdir(dav.DAV.Homedir, r.Website.DocumentRoot) {
+			return r
+		}
+	}
+	return nil
+}
+
+func hasMatchingDAVAccount(user *User, r *Resource) bool {
+	return findMatchingDAVAccount(user, r) != nil
+}
+
+func (v *validationContext) validDomainResource() ResourceValidatorFunc {
+	domainValidator := allOf(
+		minLength(6),
+		validDomainName,
+		v.isAvailableDomain(),
+	)
+
+	return func(ctx context.Context, r *Resource, user *User) error {
+		if err := v.validateShardedResource(ctx, r, user); err != nil {
+			return err
+		}
+
+		// Web resources aren't nested.
+		if !r.ParentID.Empty() {
+			return errors.New("resource should not have parent")
+		}
+
+		if r.Website == nil {
+			return errors.New("resource has no website metadata")
+		}
+		if err := domainValidator(ctx, r.ID.Name()); err != nil {
+			return err
+		}
+		if r.Website.ParentDomain != "" {
+			return errors.New("non-empty parent_domain on domain resource")
+		}
+
+		// Document root checks.
+		if r.Website.DocumentRoot == "" {
+			return errors.New("empty document_root")
+		}
+		if !isSubdir(v.webroot, r.Website.DocumentRoot) {
+			return errors.New("document root outside of web root")
+		}
+		if !hasMatchingDAVAccount(user, r) {
+			return errors.New("website has no matching DAV account")
+		}
+		return nil
+	}
+}
+
+func (v *validationContext) validWebsiteResource() ResourceValidatorFunc {
+	nameValidator := allOf(
+		minLength(6),
+		matchSitenameRx(),
+		v.isAvailableWebsite(),
+	)
+	parentValidator := v.isAllowedDomain(ResourceTypeWebsite)
+
+	return func(ctx context.Context, r *Resource, user *User) error {
+		if err := v.validateShardedResource(ctx, r, user); err != nil {
+			return err
+		}
+
+		// Web resources aren't nested.
+		if !r.ParentID.Empty() {
+			return errors.New("resource should not have parent")
+		}
+
+		if r.Website == nil {
+			return errors.New("resource has no website metadata")
+		}
+		if err := nameValidator(ctx, r.ID.Name()); err != nil {
+			return err
+		}
+		if err := parentValidator(ctx, r.Website.ParentDomain); err != nil {
+			return err
+		}
+
+		// Document root checks: must not be empty, and the
+		// user must have a DAV account with a home directory
+		// that is a parent of this document root.
+		if r.Website.DocumentRoot == "" {
+			return errors.New("empty document_root")
+		}
+		if !isSubdir(v.webroot, r.Website.DocumentRoot) {
+			return errors.New("document root outside of web root")
+		}
+		if !hasMatchingDAVAccount(user, r) {
+			return errors.New("website has no matching DAV account")
+		}
+		return nil
+	}
+}
+
+func (v *validationContext) validDAVResource() ResourceValidatorFunc {
+	return func(ctx context.Context, r *Resource, user *User) error {
+		if err := v.validateShardedResource(ctx, r, user); err != nil {
+			return err
+		}
+
+		// DAV resources aren't nested.
+		if !r.ParentID.Empty() {
+			return errors.New("resource should not have parent")
+		}
+
+		if r.DAV == nil {
+			return errors.New("resource has no dav metadata")
+		}
+		if !isSubdir(v.webroot, r.DAV.Homedir) {
+			return errors.New("homedir outside of web root")
+		}
+		return nil
+	}
+}
+
+func (v *validationContext) validDatabaseResource() ResourceValidatorFunc {
+	return func(ctx context.Context, r *Resource, user *User) error {
+		if err := v.validateShardedResource(ctx, r, user); err != nil {
+			return err
+		}
+
+		// Database resources must be nested below a website.
+		if r.ParentID.Empty() {
+			return errors.New("database resources should be nested")
+		}
+		switch r.ParentID.Type() {
+		case ResourceTypeWebsite, ResourceTypeDomain:
+		default:
+			return errors.New("database parent is not a website resource")
+		}
+
+		if r.Database == nil {
+			return errors.New("resource has no database metadata")
+		}
+		return nil
+	}
+}
+
+// Validator for arbitrary resource types.
+type resourceValidator struct {
+	rvs map[string]ResourceValidatorFunc
+}
+
+func newResourceValidator(v *validationContext) *resourceValidator {
+	return &resourceValidator{
+		rvs: map[string]ResourceValidatorFunc{
+			ResourceTypeEmail:       v.validEmailResource(),
+			ResourceTypeMailingList: v.validListResource(),
+			ResourceTypeDomain:      v.validDomainResource(),
+			ResourceTypeWebsite:     v.validWebsiteResource(),
+			ResourceTypeDAV:         v.validDAVResource(),
+			ResourceTypeDatabase:    v.validDatabaseResource(),
+		},
+	}
+}
+
+func (v *resourceValidator) validateResource(ctx context.Context, r *Resource, user *User) error {
+	return v.rvs[r.ID.Type()](ctx, r, user)
+}
+
+// Common validators for specific field types.
+type fieldValidators struct {
+	password ValidatorFunc
+	email    ValidatorFunc
+}
+
+func newFieldValidators(v *validationContext) *fieldValidators {
+	return &fieldValidators{
+		password: v.validPassword(),
+		email:    v.validHostedEmail(),
+	}
+}
+
+// UserValidatorFunc is a compound validator for User objects.
+type UserValidatorFunc func(context.Context, *User) error
+
+// A custom validator for User objects.
+func (v *validationContext) validUser() UserValidatorFunc {
+	nameValidator := v.validHostedEmail()
+	return func(ctx context.Context, user *User) error {
+		return nameValidator(ctx, user.Name)
+	}
+}
+
+// A ResourceTemplateFunc fills up server-generated fields with
+// defaults for newly created resources. Called before validation.
+type ResourceTemplateFunc func(context.Context, *Resource, *User)
+
+type templateContext struct {
+	shards  shardBackend
+	webroot string
+}
+
+func (c *templateContext) pickShard(ctx context.Context, r *Resource) string {
+	avail := c.shards.GetAvailableShards(ctx, r.ID.Type())
+	if len(avail) == 0 {
+		return ""
+	}
+	return avail[rand.Intn(len(avail))]
+}
+
+func (c *templateContext) setResourceShard(ctx context.Context, r *Resource, ref *Resource) {
+	if r.Shard == "" {
+		if ref != nil {
+			// If we are evaluating templates out of
+			// order, the reference resource may not have
+			// a shard yet. Assign it now.
+			if ref.Shard == "" {
+				ref.Shard = c.pickShard(ctx, ref)
+			}
+			r.Shard = ref.Shard
+		} else {
+			r.Shard = c.pickShard(ctx, r)
+		}
+	}
+	if r.OriginalShard == "" {
+		r.OriginalShard = r.Shard
+	}
+}
+
+func (c *templateContext) setResourceStatus(r *Resource) {
+	if r.Status == "" {
+		r.Status = ResourceStatusActive
+	}
+}
+
+func (c *templateContext) emailResourceTemplate(ctx context.Context, r *Resource, user *User) {
+	if r.Email == nil {
+		r.Email = new(Email)
+	}
+	addrParts := strings.Split(r.ID.Name(), "@")
+	r.Email.Maildir = fmt.Sprintf("%s/%s", addrParts[1], addrParts[0])
+	r.Email.QuotaLimit = 4096
+	c.setResourceShard(ctx, r, nil)
+	c.setResourceStatus(r)
+}
+
+func (c *templateContext) websiteResourceTemplate(ctx context.Context, r *Resource, user *User) {
+	if r.Website == nil {
+		r.Website = new(Website)
+	}
+	if r.Website.DocumentRoot == "" {
+		if dav := user.GetSingleResourceByType(ResourceTypeDAV); dav != nil {
+			// The DAV resource may not have been templatized yet.
+			if dav.DAV == nil || dav.DAV.Homedir == "" {
+				c.davResourceTemplate(ctx, dav, user)
+			}
+			r.Website.DocumentRoot = filepath.Join(dav.DAV.Homedir, "html-"+r.ID.Name())
+		}
+	}
+	r.Website.DocumentRoot = filepath.Clean(r.Website.DocumentRoot)
+
+	if len(r.Website.Options) == 0 {
+		r.Website.Options = []string{"nomail"}
+	}
+
+	dav := findMatchingDAVAccount(user, r)
+	c.setResourceShard(ctx, r, dav)
+	c.setResourceStatus(r)
+
+	log.Printf("applyTemplate(%s) -> %+v", r.ID, r.Website)
+}
+
+func (c *templateContext) davResourceTemplate(ctx context.Context, r *Resource, user *User) {
+	if r.DAV == nil {
+		r.DAV = new(WebDAV)
+	}
+	if r.DAV.Homedir == "" {
+		r.DAV.Homedir = filepath.Join(c.webroot, r.ID.Name())
+	}
+	r.DAV.Homedir = filepath.Clean(r.DAV.Homedir)
+
+	c.setResourceShard(ctx, r, nil)
+	c.setResourceStatus(r)
+
+	log.Printf("applyTemplate(%s) -> %+v", r.ID, r.DAV)
+}
+
+func (c *templateContext) databaseResourceTemplate(ctx context.Context, r *Resource, user *User) {
+	if r.Database == nil {
+		r.Database = new(Database)
+	}
+	if r.Database.DBUser == "" {
+		r.Database.DBUser = r.ID.Name()
+	}
+
+	c.setResourceShard(ctx, r, user.GetResourceByID(r.ParentID))
+	c.setResourceStatus(r)
+
+	log.Printf("applyTemplate(%s) -> %+v", r.ID, r.Database)
+}
+
+func (c *templateContext) applyTemplate(ctx context.Context, r *Resource, user *User) {
+	switch r.ID.Type() {
+	case ResourceTypeEmail:
+		c.emailResourceTemplate(ctx, r, user)
+	case ResourceTypeWebsite, ResourceTypeDomain:
+		c.websiteResourceTemplate(ctx, r, user)
+	case ResourceTypeDAV:
+		c.davResourceTemplate(ctx, r, user)
+	case ResourceTypeDatabase:
+		c.databaseResourceTemplate(ctx, r, user)
+	}
+}
+
+func isSubdir(root, dir string) bool {
+	return strings.HasPrefix(dir, root+"/")
+}
diff --git a/validators_test.go b/validators_test.go
index 6dbbae6be6f34ef6b2281cd7f78fd11bc8840efb..95845bb80ae02d3146d41393f09372acb36546ff 100644
--- a/validators_test.go
+++ b/validators_test.go
@@ -2,6 +2,7 @@ package accountserver
 
 import (
 	"context"
+	"fmt"
 	"testing"
 )
 
@@ -59,27 +60,45 @@ func TestValidator_MatchUsername(t *testing.T) {
 	runValidationTest(t, matchUsernameRx(), td)
 }
 
-type fakeCheckBackend map[string]struct{}
+type fakeCheckBackend struct {
+	// We need this just to satisfy the Backend interface.
+	*fakeBackend
 
-func newFakeCheckBackend(rids ...string) fakeCheckBackend {
-	f := make(fakeCheckBackend)
-	for _, rid := range rids {
-		f[rid] = struct{}{}
+	m map[string]struct{}
+}
+
+type fakeCheckTX struct {
+	TX
+	m map[string]struct{}
+}
+
+func newFakeCheckBackend(seeds []FindResourceRequest) *fakeCheckBackend {
+	f := &fakeCheckBackend{
+		fakeBackend: createFakeBackend(),
+		m:           make(map[string]struct{}),
+	}
+	for _, s := range seeds {
+		f.m[fmt.Sprintf("%s/%s", s.Type, s.Name)] = struct{}{}
 	}
 	return f
 }
 
-func (f fakeCheckBackend) HasAnyResource(_ context.Context, ids []string) (bool, error) {
-	for _, id := range ids {
-		if _, ok := f[id]; ok {
+func (f *fakeCheckBackend) NewTransaction() (TX, error) {
+	tx, _ := f.fakeBackend.NewTransaction()
+	return &fakeCheckTX{TX: tx, m: f.m}, nil
+}
+
+func (f *fakeCheckTX) HasAnyResource(_ context.Context, reqs []FindResourceRequest) (bool, error) {
+	for _, req := range reqs {
+		if _, ok := f.m[fmt.Sprintf("%s/%s", req.Type, req.Name)]; ok {
 			return true, nil
 		}
 	}
 	return false, nil
 }
 
-func newTestValidationConfig(entries ...string) *validationConfig {
-	return &validationConfig{
+func newTestValidationConfig(entries ...string) *validationContext {
+	return &validationContext{
 		forbiddenUsernames: newStringSetFromList(entries),
 	}
 }
@@ -105,9 +124,11 @@ func TestValidator_HostedEmail(t *testing.T) {
 		{"existing@example.com", false},
 	}
 	vc := newTestValidationConfig("forbidden")
-	cb := newFakeCheckBackend("email/existing@example.com")
-	db := newFakeDomainBackend("example.com")
-	runValidationTest(t, validHostedEmail(vc, db, cb), td)
+	vc.backend = newFakeCheckBackend([]FindResourceRequest{
+		{Type: ResourceTypeEmail, Name: "existing@example.com"},
+	})
+	vc.domains = newFakeDomainBackend("example.com")
+	runValidationTest(t, vc.validHostedEmail(), td)
 }
 
 func TestValidator_HostedMailingList(t *testing.T) {
@@ -121,7 +142,9 @@ func TestValidator_HostedMailingList(t *testing.T) {
 		{"existing@domain2.com", false},
 	}
 	vc := newTestValidationConfig("forbidden")
-	cb := newFakeCheckBackend("list/existing@domain2.com")
-	db := newFakeDomainBackend("domain1.com", "domain2.com")
-	runValidationTest(t, validHostedMailingList(vc, db, cb), td)
+	vc.backend = newFakeCheckBackend([]FindResourceRequest{
+		{Type: ResourceTypeMailingList, Name: "existing@domain2.com"},
+	})
+	vc.domains = newFakeDomainBackend("domain1.com", "domain2.com")
+	runValidationTest(t, vc.validHostedMailingList(), td)
 }