diff --git a/actions.go b/actions.go
new file mode 100644
index 0000000000000000000000000000000000000000..ed2c61297c030a0f3a71910f67ccd39a0bae4fe1
--- /dev/null
+++ b/actions.go
@@ -0,0 +1,368 @@
+package accountserver
+
+import (
+	"context"
+	"errors"
+
+	"git.autistici.org/ai3/go-common/pwhash"
+	sso "git.autistici.org/id/go-sso"
+	"git.autistici.org/id/keystore/userenckey"
+)
+
+// 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
+	SetApplicationSpecificPassword(context.Context, *User, *AppSpecificPasswordInfo, string) error
+	DeleteApplicationSpecificPassword(context.Context, *User, string) error
+}
+
+// AccountService contains the business logic and functionality of the
+// user accounts service.
+type AccountService struct {
+	backend Backend
+
+	validator     sso.Validator
+	ssoService    string
+	ssoGroups     []string
+	ssoAdminGroup string
+}
+
+func NewAccountService(backend Backend, ssoValidator sso.Validator, ssoService string, ssoGroups []string, ssoAdminGroup string) *AccountService {
+	return &AccountService{
+		backend:       backend,
+		validator:     ssoValidator,
+		ssoService:    ssoService,
+		ssoGroups:     ssoGroups,
+		ssoAdminGroup: ssoAdminGroup,
+	}
+}
+
+func (s *AccountService) isAdmin(tkt *sso.Ticket) bool {
+	for _, g := range tkt.Groups {
+		if g == s.ssoAdminGroup {
+			return true
+		}
+	}
+	return false
+}
+
+var (
+	ErrUnauthorized     = errors.New("unauthorized")
+	ErrUserNotFound     = errors.New("user not found")
+	ErrResourceNotFound = errors.New("resource not found")
+)
+
+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, ErrUnauthorized
+	}
+
+	// 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, ErrUnauthorized
+	}
+
+	user, err := s.backend.GetUser(ctx, username)
+	if err != nil {
+		return nil, err
+	}
+	if user == nil {
+		return nil, ErrUserNotFound
+	}
+	return user, nil
+}
+
+type RequestBase struct {
+	Username string `json:"username"`
+	SSO      string `json:"sso"`
+}
+
+type GetUserRequest struct {
+	RequestBase
+}
+
+func (s *AccountService) GetUser(ctx context.Context, req *GetUserRequest) (*User, error) {
+	return s.authorizeUser(ctx, req.Username, req.SSO)
+}
+
+var errResourceNotFound = errors.New("resource not found")
+
+func (s *AccountService) setResourceStatus(ctx context.Context, username, resourceID, status string) error {
+	r, err := s.backend.GetResource(ctx, username, resourceID)
+	if err != nil {
+		return errResourceNotFound
+	}
+	r.Status = status
+	return s.backend.UpdateResource(ctx, username, r)
+}
+
+type DisableResourceRequest struct {
+	RequestBase
+	ResourceID string `json:"resource_id"`
+}
+
+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)
+}
+
+type EnableResourceRequest struct {
+	RequestBase
+	ResourceID string `json:"resource_id"`
+}
+
+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)
+}
+
+type ChangeUserPasswordRequest struct {
+	RequestBase
+	OldPassword string `json:"old_password"`
+	Password    string `json:"password"`
+}
+
+func (s *AccountService) ChangeUserPassword(ctx context.Context, req *ChangeUserPasswordRequest) error {
+	user, err := s.authorizeUser(ctx, req.Username, req.SSO)
+	if err != nil {
+		return err
+	}
+
+	if err := s.updateUserEncryptionKeys(ctx, user, req.OldPassword, req.Password, UserEncryptionKeyMainID); err != nil {
+		return err
+	}
+
+	// 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 err
+	}
+	for _, r := range user.GetResourcesByType(ResourceTypeEmail) {
+		if err := s.backend.SetResourcePassword(ctx, user.Name, r, encPass); err != nil {
+			return err
+		}
+	}
+
+	return nil
+}
+
+func (s *AccountService) updateUserEncryptionKeys(ctx context.Context, user *User, curPassword, newPassword, keyID string) error {
+	// Re-encrypt the user encryption key with the new password.
+	keys, err := s.backend.GetUserEncryptionKeys(ctx, user)
+	if err != nil {
+		return err
+	}
+
+	keys, err = reEncryptUserKeys(keys, curPassword, newPassword, keyID)
+	if err != nil {
+		return err
+	}
+	return s.backend.SetUserEncryptionKeys(ctx, user, keys)
+}
+
+// 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
+	}
+
+	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
+}
+
+type CreateApplicationSpecificPasswordRequest struct {
+	RequestBase
+	Password string `json:"password"` // User password
+	Service  string `json:"service"`
+	Comment  string `json:"comment"`
+}
+
+type CreateApplicationSpecificPasswordResponse struct {
+	Password string `json:"password"`
+}
+
+func (s *AccountService) CreateApplicationSpecificPassword(ctx context.Context, req *CreateApplicationSpecificPasswordRequest) (*CreateApplicationSpecificPasswordResponse, error) {
+	user, err := s.authorizeUser(ctx, req.Username, req.SSO)
+	if err != nil {
+		return nil, 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.Comment,
+	}
+	password := randomAppSpecificPassword()
+	encPass := pwhash.Encrypt(password)
+	if err := s.backend.SetApplicationSpecificPassword(ctx, user, asp, encPass); 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.
+	keyID := "asp_" + asp.ID
+	if err := s.updateUserEncryptionKeys(ctx, user, req.Password, password, keyID); err != nil {
+		return nil, err
+	}
+
+	return &CreateApplicationSpecificPasswordResponse{
+		Password: password,
+	}, nil
+}
+
+type DeleteApplicationSpecificPasswordRequest struct {
+	RequestBase
+	AspID string `json:"asp_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)
+		}
+	}
+	return s.backend.SetUserEncryptionKeys(ctx, user, newKeys)
+}
+
+type ChangeResourcePasswordRequest struct {
+	RequestBase
+	ResourceID string `json:"resource_id"`
+	Password   string `json:"password"`
+}
+
+func (s *AccountService) ChangeResourcePassword(ctx context.Context, req *ChangeResourcePasswordRequest) error {
+	_, err := s.authorizeUser(ctx, req.Username, req.SSO)
+	if err != nil {
+		return err
+	}
+
+	r, err := s.backend.GetResource(ctx, req.Username, req.ResourceID)
+	if err != nil {
+		return err
+	}
+
+	encPass := pwhash.Encrypt(req.Password)
+	return s.backend.SetResourcePassword(ctx, req.Username, r, encPass)
+}
+
+type MoveResourceRequest struct {
+	RequestBase
+	ResourceID string `json:"resource_id"`
+	Shard      string `json:"shard"`
+}
+
+type MoveResourceResponse struct {
+	MovedIDs []string `json:"moved_ids"`
+}
+
+func (s *AccountService) MoveResource(ctx context.Context, req *MoveResourceRequest) (*MoveResourceResponse, error) {
+	user, err := s.authorizeUser(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 != "" {
+		for _, r := range user.GetResourcesByGroup(r.Group) {
+			resources = append(resources, r)
+		}
+	} else {
+		resources = []*Resource{r}
+	}
+
+	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
+		}
+		resp.MovedIDs = append(resp.MovedIDs, r.ID)
+	}
+
+	return &resp, nil
+}
+
+func randomAppSpecificPassword() string {
+	return "haha"
+}
+
+func randomAppSpecificPasswordID() string {
+	return "1234"
+}
diff --git a/backend/composite_values.go b/backend/composite_values.go
new file mode 100644
index 0000000000000000000000000000000000000000..e6287f54d3bca02e9309a0165221210ddaa151c0
--- /dev/null
+++ b/backend/composite_values.go
@@ -0,0 +1,65 @@
+package backend
+
+import (
+	"errors"
+	"strings"
+
+	"git.autistici.org/ai3/accountserver"
+)
+
+type appSpecificPassword struct {
+	accountserver.AppSpecificPasswordInfo
+	Password string
+}
+
+func (p *appSpecificPassword) Encode() string {
+	return strings.Join([]string{
+		p.Service,
+		p.Password,
+		p.Comment,
+	}, ":")
+}
+
+func newAppSpecificPassword(info accountserver.AppSpecificPasswordInfo, pw string) *appSpecificPassword {
+	return &appSpecificPassword{
+		AppSpecificPasswordInfo: info,
+		Password:                pw,
+	}
+}
+
+func parseAppSpecificPassword(asp string) (*appSpecificPassword, error) {
+	parts := strings.Split(asp, ":")
+	if len(parts) != 3 {
+		return nil, errors.New("badly encoded app-specific password")
+	}
+	return newAppSpecificPassword(accountserver.AppSpecificPasswordInfo{
+		Service: parts[0],
+		Comment: parts[2],
+	}, parts[1]), nil
+}
+
+func decodeAppSpecificPasswords(values []string) []*appSpecificPassword {
+	var out []*appSpecificPassword
+	for _, value := range values {
+		if asp, err := parseAppSpecificPassword(value); err == nil {
+			out = append(out, asp)
+		}
+	}
+	return out
+}
+
+func encodeAppSpecificPasswords(asps []*appSpecificPassword) []string {
+	var out []string
+	for _, asp := range asps {
+		out = append(out, asp.Encode())
+	}
+	return out
+}
+
+func getASPInfo(asps []*appSpecificPassword) []*accountserver.AppSpecificPasswordInfo {
+	var out []*accountserver.AppSpecificPasswordInfo
+	for _, asp := range asps {
+		out = append(out, &asp.AppSpecificPasswordInfo)
+	}
+	return out
+}
diff --git a/backend/model.go b/backend/model.go
index 793186e718d95760603d0ed35a0d2ccfb0154b7e..8baf77aab515886aaa7012f4b2832baf9e13f982 100644
--- a/backend/model.go
+++ b/backend/model.go
@@ -30,9 +30,16 @@ type LDAPBackend struct {
 	resourceQueries     map[string]*queryConfig
 }
 
+const ldapPoolSize = 20
+
 // NewLDAPBackend initializes an LDAPBackend object with the given LDAP
 // connection pool.
-func NewLDAPBackend(pool *ldaputil.ConnectionPool, base string) *LDAPBackend {
+func NewLDAPBackend(uri, bindDN, bindPw, base string) (*LDAPBackend, error) {
+	pool, err := ldaputil.NewConnectionPool(uri, bindDN, bindPw, ldapPoolSize)
+	if err != nil {
+		return nil, err
+	}
+
 	return &LDAPBackend{
 		conn: pool,
 		userQuery: mustCompileQueryConfig(&queryConfig{
@@ -79,7 +86,7 @@ func NewLDAPBackend(pool *ldaputil.ConnectionPool, base string) *LDAPBackend {
 				Scope:  "one",
 			}),
 		},
-	}
+	}, nil
 }
 
 func replaceVars(s string, vars map[string]string) string {
@@ -320,6 +327,10 @@ func databaseResourceToLDAP(r *accountserver.Resource) []ldap.PartialAttribute {
 	}
 }
 
+type ldapUserData struct {
+	dn string
+}
+
 func newUser(entry *ldap.Entry) (*accountserver.User, error) {
 	user := &accountserver.User{
 		Name:   entry.GetAttributeValue("uid"),
@@ -330,9 +341,18 @@ func newUser(entry *ldap.Entry) (*accountserver.User, error) {
 	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
+}
+
 // GetUser returns a user.
 func (b *LDAPBackend) GetUser(ctx context.Context, username string) (*accountserver.User, error) {
 	// First of all, find the main user object, and just that one.
@@ -366,7 +386,7 @@ func (b *LDAPBackend) GetUser(ctx context.Context, username string) (*accountser
 			// them on the main User object.
 			if isObjectClass(entry, "virtualMailUser") {
 				user.PasswordRecoveryHint = entry.GetAttributeValue("recoverQuestion")
-				setAppSpecificPasswords(user, entry.GetAttributeValues("appSpecificPassword"))
+				user.AppSpecificPasswords = getASPInfo(decodeAppSpecificPasswords(entry.GetAttributeValues("appSpecificPassword")))
 			}
 			// Parse the resource and add it to the User.
 			if r, err := parseLdapResource(entry); err == nil {
@@ -380,6 +400,122 @@ 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
+	}
+	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)
+}
+
+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 (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 (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))
+	}
+	return b.conn.Modify(ctx, mod)
+}
+
+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
+	for _, asp := range asps {
+		if asp.ID != info.ID {
+			outASPs = append(outASPs, 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)
+}
+
+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)
+
+	asps := decodeAppSpecificPasswords(b.readAttributeValues(ctx, emailDN, "appSpecificPassword"))
+	var outASPs []*appSpecificPassword
+	for _, asp := range asps {
+		if asp.ID != id {
+			outASPs = append(outASPs, asp)
+		}
+	}
+
+	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))
+	}
+	return b.conn.Modify(ctx, mod)
+}
+
+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 parseResourceID(resourceID string) (string, string) {
 	parts := strings.SplitN(resourceID, "/", 2)
 	return parts[0], parts[1]
@@ -405,22 +541,12 @@ func (b *LDAPBackend) GetResource(ctx context.Context, username, resourceID stri
 		return nil, err
 	}
 
-	r, err := parseLdapResource(result.Entries[0])
-	if err != nil {
-		return nil, err
-	}
-
-	r.SetBackendHandle(&ldapObjectData{
-		dn:       result.Entries[0].DN,
-		original: r.Copy(),
-	})
-
-	return r, nil
+	return parseLdapResource(result.Entries[0])
 }
 
 // UpdateResource updates a LDAP-backed resource that was obtained by a previous GetResource call.
-func (b *LDAPBackend) UpdateResource(ctx context.Context, username string, r *accountserver.Resource) error {
-	lo, ok := r.GetBackendHandle().(*ldapObjectData)
+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")
 	}
@@ -438,20 +564,37 @@ type ldapObjectData struct {
 	original *accountserver.Resource
 }
 
-func parseLdapResource(entry *ldap.Entry) (*accountserver.Resource, error) {
+func getResourceDN(r *accountserver.Resource) string {
+	lo, ok := r.Opaque.(*ldapObjectData)
+	if !ok {
+		panic("no ldap resource data")
+	}
+	return lo.dn
+}
+
+func parseLdapResource(entry *ldap.Entry) (r *accountserver.Resource, err error) {
 	switch {
 	case isObjectClass(entry, "virtualMailUser"):
-		return newEmailResource(entry)
+		r, err = newEmailResource(entry)
 	case isObjectClass(entry, "ftpAccount"):
-		return newWebDAVResource(entry)
+		r, err = newWebDAVResource(entry)
 	case isObjectClass(entry, "mailingList"):
-		return newMailingListResource(entry)
+		r, err = newMailingListResource(entry)
 	case isObjectClass(entry, "dbMysql"):
-		return newDatabaseResource(entry)
+		r, err = newDatabaseResource(entry)
 	case isObjectClass(entry, "subSite") || isObjectClass(entry, "virtualHost"):
-		return newWebsiteResource(entry)
+		r, err = newWebsiteResource(entry)
+	default:
+		return nil, errors.New("unknown LDAP resource")
+	}
+	if err != nil {
+		return
 	}
-	return nil, errors.New("unknown LDAP resource")
+	r.Opaque = &ldapObjectData{
+		dn:       entry.DN,
+		original: r.Copy(),
+	}
+	return
 }
 
 func isObjectClass(entry *ldap.Entry, class string) bool {
@@ -464,25 +607,6 @@ func isObjectClass(entry *ldap.Entry, class string) bool {
 	return false
 }
 
-func parseAppSpecificPassword(asp string) (*accountserver.AppSpecificPasswordInfo, error) {
-	parts := strings.Split(asp, ":")
-	if len(parts) != 3 {
-		return nil, errors.New("badly encoded app-specific password")
-	}
-	return &accountserver.AppSpecificPasswordInfo{
-		Service: parts[0],
-		Comment: parts[2],
-	}, nil
-}
-
-func setAppSpecificPasswords(user *accountserver.User, asps []string) {
-	for _, asp := range asps {
-		if ainfo, err := parseAppSpecificPassword(asp); err == nil {
-			user.AppSpecificPasswords = append(user.AppSpecificPasswords, ainfo)
-		}
-	}
-}
-
 var siteRoot = "/home/users/investici.org/"
 
 // The hosting directory for a website is the path component immediately after
diff --git a/backend/model_test.go b/backend/model_test.go
index 664170428cc039345083631a35d241277a44870f..da98a09cd65249b8c66449f3454e34732b43b90e 100644
--- a/backend/model_test.go
+++ b/backend/model_test.go
@@ -8,6 +8,15 @@ import (
 	ldap "gopkg.in/ldap.v2"
 )
 
+// 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 TestEmailResource_FromLDAP(t *testing.T) {
 	entry := ldap.NewEntry(
 		"mail=test@investici.org,ou=People,dc=investici,dc=org,o=Anarchy",
@@ -39,7 +48,7 @@ func TestEmailResource_FromLDAP(t *testing.T) {
 			Maildir: "test/store",
 		},
 	}
-	if !reflect.DeepEqual(r, expected) {
+	if !resourcesEqual(r, expected) {
 		t.Fatalf("bad result: got %+v, expected %+v", r, expected)
 	}
 }
diff --git a/cmd/accountserver/main.go b/cmd/accountserver/main.go
index 9b7fef416a57451c94783ce2f2cccd1a0094d65e..b9d116614171660d8df6a1e585c177c26be1bc3f 100644
--- a/cmd/accountserver/main.go
+++ b/cmd/accountserver/main.go
@@ -7,9 +7,10 @@ import (
 	"log"
 	"strings"
 
-	ldaputil "git.autistici.org/ai3/go-common/ldap"
+	"git.autistici.org/ai3/accountserver"
 	"git.autistici.org/ai3/go-common/serverutil"
-	"gopkg.in/yaml.v1"
+	"git.autistici.org/id/go-sso"
+	"gopkg.in/yaml.v2"
 
 	"git.autistici.org/ai3/accountserver/backend"
 	"git.autistici.org/ai3/accountserver/server"
@@ -28,6 +29,13 @@ type Config struct {
 		BindPwFile string `yaml:"bind_pw_file"`
 		BaseDN     string `yaml:"base_dn"`
 	} `yaml:"ldap"`
+	SSO struct {
+		PublicKeyFile string   `yaml:"public_key"`
+		Domain        string   `yaml:"domain"`
+		Service       string   `yaml:"service"`
+		Groups        []string `yaml:"groups"`
+		AdminGroup    string   `yaml:"admin_group"`
+	} `yaml:"sso"`
 	ServerConfig *serverutil.ServerConfig `yaml:"http_server"`
 }
 
@@ -41,6 +49,15 @@ func (c *Config) Validate() error {
 	if (c.LDAP.BindPwFile == "" && c.LDAP.BindPw == "") || (c.LDAP.BindPwFile != "" && c.LDAP.BindPw != "") {
 		return errors.New("only one of ldap.bind_pw_file or ldap.bind_pw must be set")
 	}
+	if c.SSO.PublicKeyFile == "" {
+		return errors.New("empty sso.public_key")
+	}
+	if c.SSO.Domain == "" {
+		return errors.New("empty sso.domain")
+	}
+	if c.SSO.Service == "" {
+		return errors.New("empty sso.service")
+	}
 	return nil
 }
 
@@ -60,18 +77,26 @@ func loadConfig(path string) (*Config, error) {
 	return &config, nil
 }
 
-func connectLDAP(config *Config) (*ldaputil.ConnectionPool, error) {
+func getBindPw(config *Config) (string, error) {
 	// Read the bind password.
 	bindPw := config.LDAP.BindPw
 	if config.LDAP.BindPwFile != "" {
 		pwData, err := ioutil.ReadFile(config.LDAP.BindPwFile)
 		if err != nil {
-			return nil, err
+			return "", err
 		}
 		bindPw = strings.TrimSpace(string(pwData))
 	}
 
-	return ldaputil.NewConnectionPool(config.LDAP.URI, config.LDAP.BindDN, bindPw, 5)
+	return bindPw, nil
+}
+
+func initSSO(config *Config) (sso.Validator, error) {
+	pkey, err := ioutil.ReadFile(config.SSO.PublicKeyFile)
+	if err != nil {
+		return nil, err
+	}
+	return sso.NewValidator(pkey, config.SSO.Domain)
 }
 
 func main() {
@@ -83,13 +108,34 @@ func main() {
 		log.Fatal(err)
 	}
 
-	pool, err := connectLDAP(config)
+	bindPw, err := getBindPw(config)
+	if err != nil {
+		log.Fatal(err)
+	}
+
+	be, err := backend.NewLDAPBackend(
+		config.LDAP.URI,
+		config.LDAP.BindDN,
+		bindPw,
+		config.LDAP.BaseDN,
+	)
+	if err != nil {
+		log.Fatal(err)
+	}
+
+	validator, err := initSSO(config)
 	if err != nil {
 		log.Fatal(err)
 	}
+	service := accountserver.NewAccountService(
+		be,
+		validator,
+		config.SSO.Service,
+		config.SSO.Groups,
+		config.SSO.AdminGroup,
+	)
 
-	be := backend.NewLDAPBackend(pool, config.LDAP.BaseDN)
-	as := server.New(be)
+	as := server.New(service)
 
 	if err := serverutil.Serve(as.Handler(), config.ServerConfig, *addr); err != nil {
 		log.Fatal(err)
diff --git a/server/server.go b/server/server.go
index 66eb927f9635fe8d77c5d27a5810203016af48bb..643482700b48e34abed6893b4b0c9d7f7ac58619 100644
--- a/server/server.go
+++ b/server/server.go
@@ -1,88 +1,167 @@
 package server
 
 import (
-	"context"
 	"log"
 	"net/http"
 
-	"git.autistici.org/ai/go-sso"
 	"git.autistici.org/ai3/go-common/serverutil"
 
-	"git.autistici.org/ai3/accountserver"
+	as "git.autistici.org/ai3/accountserver"
 )
 
-type Backend interface {
-	GetUser(context.Context, string) (*accountserver.User, error)
-	//GetResource(context.Context, string, string) (*accountserver.Resource, error)
+type AccountServer struct {
+	service *as.AccountService
 }
 
-type AccountServer struct {
-	backend   Backend
-	validator sso.Validator
+func New(service *as.AccountService) *AccountServer {
+	return &AccountServer{service}
+}
+
+var emptyResponse = map[string]string{}
+
+func errToStatus(err error) int {
+	switch err {
+	case as.ErrUserNotFound, as.ErrResourceNotFound:
+		return http.StatusNotFound
+	case as.ErrUnauthorized:
+		return http.StatusUnauthorized
+	default:
+		return http.StatusInternalServerError
+	}
 }
 
-func New(backend Backend, ssoPublicKey []byte, ssoDomain string) (*AccountServer, error) {
-	v, err := sso.NewValidator(ssoPublicKey, ssoDomain)
+func (s *AccountServer) handleGetUser(w http.ResponseWriter, r *http.Request) {
+	var req as.GetUserRequest
+	if !serverutil.DecodeJSONRequest(w, r, &req) {
+		return
+	}
+
+	user, err := s.service.GetUser(r.Context(), &req)
 	if err != nil {
-		return nil, err
+		log.Printf("GetUser(%s): error: %v", req.Username, err)
+		http.Error(w, err.Error(), errToStatus(err))
+		return
 	}
-	return &AccountServer{
-		backend:   backend,
-		validator: v,
-	}, nil
+
+	serverutil.EncodeJSONResponse(w, user)
 }
 
-var adminGroup = "admins"
+func (s *AccountServer) handleChangeUserPassword(w http.ResponseWriter, r *http.Request) {
+	var req as.ChangeUserPasswordRequest
+	if !serverutil.DecodeJSONRequest(w, r, &req) {
+		return
+	}
 
-func isAdmin(tkt *sso.Ticket) bool {
-	for _, g := range tkt.Groups {
-		if g == adminGroup {
-			return true
-		}
+	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
 	}
-	return false
+
+	serverutil.EncodeJSONResponse(w, emptyResponse)
 }
 
-func (s *AccountServer) authorize(w http.ResponseWriter, ssoToken, username string) bool {
-	tkt, err := s.validator.Validate(ssoToken, "", s.ssoService, s.ssoGroups)
+func (s *AccountServer) handleCreateApplicationSpecificPassword(w http.ResponseWriter, r *http.Request) {
+	var req as.CreateApplicationSpecificPasswordRequest
+	if !serverutil.DecodeJSONRequest(w, r, &req) {
+		return
+	}
+
+	resp, err := s.service.CreateApplicationSpecificPassword(r.Context(), &req)
 	if err != nil {
-		log.Printf("authentication error: %v", err)
-		http.Error(w, err.Error(), http.StatusUnauthorized)
-		return false
+		log.Printf("CreateApplicationSpecificPassword(%s): error: %v", req.Username, err)
+		http.Error(w, err.Error(), errToStatus(err))
+		return
+	}
+
+	serverutil.EncodeJSONResponse(w, resp)
+}
+
+func (s *AccountServer) handleDeleteApplicationSpecificPassword(w http.ResponseWriter, r *http.Request) {
+	var req as.DeleteApplicationSpecificPasswordRequest
+	if !serverutil.DecodeJSONRequest(w, r, &req) {
+		return
 	}
 
-	// Requests are allowed if the SSO ticket corresponds to an admin, or if
-	// it identifies the same user that we're querying.
-	if !isAdmin(tkt) && tkt.User != username {
-		log.Printf("unauthorized access to %s from %s", username, tkt.User)
-		http.Error(w, err.Error(), http.StatusUnauthorized)
-		return false
+	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
 	}
 
-	return true
+	serverutil.EncodeJSONResponse(w, emptyResponse)
 }
 
-func (s *AccountServer) handleGetUser(w http.ResponseWriter, r *http.Request) {
-	var req accountserver.GetUserRequest
+func (s *AccountServer) handleEnableResource(w http.ResponseWriter, r *http.Request) {
+	var req as.EnableResourceRequest
 	if !serverutil.DecodeJSONRequest(w, r, &req) {
 		return
 	}
-	if !s.authorize(w, req.SSO, req.Username) {
+
+	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
 	}
 
-	user, err := s.backend.GetUser(r.Context(), req.Username)
+	serverutil.EncodeJSONResponse(w, emptyResponse)
+}
+
+func (s *AccountServer) handleDisableResource(w http.ResponseWriter, r *http.Request) {
+	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)
+}
+
+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) handleMoveResource(w http.ResponseWriter, r *http.Request) {
+	var req as.MoveResourceRequest
+	if !serverutil.DecodeJSONRequest(w, r, &req) {
+		return
+	}
+
+	resp, err := s.service.MoveResource(r.Context(), &req)
 	if err != nil {
-		log.Printf("GetUser(%s): error: %v", req.Username, err)
-		http.Error(w, err.Error(), http.StatusInternalServerError)
+		log.Printf("MoveResource(%s): error: %v", req.Username, err)
+		http.Error(w, err.Error(), errToStatus(err))
 		return
 	}
 
-	serverutil.EncodeJSONResponse(w, &accountserver.GetUserResponse{User: user})
+	serverutil.EncodeJSONResponse(w, resp)
 }
 
 func (s *AccountServer) Handler() http.Handler {
 	h := http.NewServeMux()
-	h.HandleFunc("/api/get_user", s.handleGetUser)
+	h.HandleFunc("/api/user/get", s.handleGetUser)
+	h.HandleFunc("/api/user/change_password", s.handleChangeUserPassword)
+	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
 }
diff --git a/types.go b/types.go
index 01211a6f3ea668e7c890b353e7b1a6d217cd8cc9..9b132dccb724d0e6273dede381fa19c499b9e425 100644
--- a/types.go
+++ b/types.go
@@ -1,6 +1,10 @@
 package accountserver
 
-import "time"
+import (
+	"fmt"
+	"strings"
+	"time"
+)
 
 const (
 	StatusActive   = "active"
@@ -27,22 +31,89 @@ type User struct {
 	// Preferred language.
 	Lang string `json:"lang"`
 
-	// Whether 2FA is enabled.
 	Has2FA               bool   `json:"has_2fa"`
+	HasEncryptionKeys    bool   `json:"has_encryption_keys"`
 	PasswordRecoveryHint string `json:"password_recovery_hint"`
 
 	AppSpecificPasswords []*AppSpecificPasswordInfo `json:"app_specific_passwords,omitempty"`
 
 	Resources []*Resource `json:"resources,omitempty"`
+
+	Opaque interface{}
+}
+
+func (u *User) GetResourcesByType(resourceType string) []*Resource {
+	var out []*Resource
+	for _, r := range u.Resources {
+		if r.Type == resourceType {
+			out = append(out, r)
+		}
+	}
+	return out
+}
+
+func (u *User) GetSingleResourceByType(resourceType string) *Resource {
+	for _, r := range u.Resources {
+		if r.Type == resourceType {
+			return r
+		}
+	}
+	return nil
+}
+
+func (u *User) GetResourcesByGroup(group string) []*Resource {
+	var out []*Resource
+	for _, r := range u.Resources {
+		if r.Group == group {
+			out = append(out, r)
+		}
+	}
+	return out
 }
 
 // AppSpecificPasswordInfo stores public information about an
 // app-specific password.
 type AppSpecificPasswordInfo struct {
+	ID      string `json:"id"`
 	Service string `json:"service"`
 	Comment string `json:"comment"`
 }
 
+const (
+	UserEncryptionKeyMainID     = "main"
+	UserEncryptionKeyRecoveryID = "recovery"
+)
+
+// UserEncryptionKey stores a password-encrypted secret key for the
+// user's encrypted storage.
+type UserEncryptionKey struct {
+	ID  string `json:"id"`
+	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
+}
+
 const (
 	ResourceTypeEmail       = "email"
 	ResourceTypeMailingList = "list"
@@ -51,6 +122,11 @@ const (
 	ResourceTypeDatabase    = "db"
 )
 
+const (
+	ResourceStatusActive   = "active"
+	ResourceStatusInactive = "inactive"
+)
+
 // 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
@@ -78,7 +154,8 @@ type Resource struct {
 	OriginalShard string `json:"original_shard,omitempty"`
 
 	// Resources can be 'grouped' together, for various reasons
-	// (display purposes, service integrity). Group names can be
+	// (display purposes, service integrity). All resources in the
+	// same group should have the same Shard. Group names can be
 	// arbitrary strings.
 	Group string `json:"group,omitempty"`
 
@@ -93,19 +170,7 @@ type Resource struct {
 	// When the resource is used internally in the accountserver,
 	// it needs a reference to backend-specific data. This is not
 	// part of the public interface, and it is not serialized.
-	opaque interface{}
-}
-
-// SetBackendHandle associates some backend-specific data at runtime
-// with this resource.
-func (r *Resource) SetBackendHandle(h interface{}) {
-	r.opaque = h
-}
-
-// GetBackendHandle returns the backend-specific data associated with
-// the resource.
-func (r *Resource) GetBackendHandle() interface{} {
-	return r.opaque
+	Opaque interface{} `json:"-"`
 }
 
 // Copy the resource (makes a deep copy).
@@ -191,14 +256,3 @@ type Blog struct {
 	Name string `json:"name"`
 	URL  string `json:"url"`
 }
-
-// RPC requests.
-
-type GetUserRequest struct {
-	SSO      string `json:"sso"`
-	Username string `json:"username"`
-}
-
-type GetUserResponse struct {
-	User *User `json:"user"`
-}