From ce95ac1ccbb2d515a074788a9787e1c0e64cf8e5 Mon Sep 17 00:00:00 2001
From: ale <ale@incal.net>
Date: Sun, 24 Jun 2018 11:02:02 +0100
Subject: [PATCH] Add APIs to create users and resources

---
 actions.go                          | 40 +++++++++++++-
 actions_test.go                     | 45 ++++++++++++++-
 backend/model.go                    | 53 ++++++++++++++++++
 backend/tx.go                       | 86 ++++++++++++++++++++++-------
 integrationtest/integration_test.go | 26 +++++++++
 server/server.go                    |  8 +++
 service.go                          |  7 ++-
 validators.go                       | 10 ++++
 8 files changed, 251 insertions(+), 24 deletions(-)

diff --git a/actions.go b/actions.go
index 9bb16400..0be40177 100644
--- a/actions.go
+++ b/actions.go
@@ -674,7 +674,7 @@ func (s *AccountService) CreateResources(ctx context.Context, tx TX, req *Create
 				log.Printf("validation error while creating resource %+v: %v", r, err)
 				return err
 			}
-			if err := tx.UpdateResource(ctx, r); err != nil {
+			if err := tx.CreateResource(ctx, r); err != nil {
 				return err
 			}
 			resp.IDs = append(resp.IDs, r.ID)
@@ -684,6 +684,44 @@ func (s *AccountService) CreateResources(ctx context.Context, tx TX, req *Create
 	return &resp, err
 }
 
+type CreateUserRequest struct {
+	SSO  string `json:"sso"`
+	User *User  `json:"user"`
+}
+
+type CreateUserResponse struct {
+	User *User `json:"user,omitempty"`
+}
+
+func (s *AccountService) CreateUser(ctx context.Context, tx TX, req *CreateUserRequest) (*CreateUserResponse, error) {
+	ctx, err := s.authorizeAdminGeneric(ctx, tx, req.SSO)
+	if err != nil {
+		return nil, err
+	}
+
+	var resp CreateUserResponse
+	err = s.withRequest(ctx, req, func(ctx context.Context) error {
+		// 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
+		}
+		for _, r := range req.User.Resources {
+			if err := s.resourceValidator.validateResource(ctx, r); err != nil {
+				log.Printf("validation error while creating resource %+v: %v", r, err)
+				return err
+			}
+		}
+
+		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 5adf9f08..7dfd107e 100644
--- a/actions_test.go
+++ b/actions_test.go
@@ -27,6 +27,11 @@ func (b *fakeBackend) GetUser(_ context.Context, username string) (*User, error)
 	return b.users[username], 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
 }
@@ -36,6 +41,11 @@ func (b *fakeBackend) UpdateResource(_ context.Context, r *Resource) error {
 	return nil
 }
 
+func (b *fakeBackend) CreateResource(_ context.Context, r *Resource) error {
+	b.resources[r.ID.User()][r.ID.String()] = r
+	return nil
+}
+
 func (b *fakeBackend) SetUserPassword(_ context.Context, user *User, password string) error {
 	b.passwords[user.Name] = password
 	return nil
@@ -317,7 +327,7 @@ func TestService_AddEmailAlias(t *testing.T) {
 	}
 }
 
-func TestService_Create(t *testing.T) {
+func TestService_CreateResource(t *testing.T) {
 	svc, tx := testService("")
 
 	req := &CreateResourcesRequest{
@@ -349,3 +359,36 @@ func TestService_Create(t *testing.T) {
 		t.Fatal("creating a duplicate resource did not fail")
 	}
 }
+
+func TestService_CreateUser(t *testing.T) {
+	svc, tx := testService("")
+
+	req := &CreateUserRequest{
+		User: &User{
+			Name: "testuser2@example.com",
+			Resources: []*Resource{
+				&Resource{
+					ID:            NewResourceID(ResourceTypeDomain, "testuser2@example.com", "example2.com"),
+					Name:          "example2.com",
+					Status:        ResourceStatusActive,
+					Shard:         "host2",
+					OriginalShard: "host2",
+					Website: &Website{
+						URL:          "https://example2.com",
+						DocumentRoot: "/home/sites/example2.com",
+						AcceptMail:   true,
+					},
+				},
+			},
+		},
+	}
+
+	// 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)
+	}
+}
diff --git a/backend/model.go b/backend/model.go
index bf071309..5f110243 100644
--- a/backend/model.go
+++ b/backend/model.go
@@ -109,10 +109,48 @@ func newUser(entry *ldap.Entry) (*accountserver.User, error) {
 	return user, nil
 }
 
+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: "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
+}
+
 func (tx *backendTX) getUserDN(user *accountserver.User) string {
 	return joinDN("uid="+user.Name, "ou=People", tx.backend.baseDN)
 }
 
+// CreateUser creates a new user.
+func (tx *backendTX) CreateUser(ctx context.Context, user *accountserver.User) error {
+	dn := tx.getUserDN(user)
+
+	tx.create(dn)
+	for _, attr := range userToLDAP(user) {
+		tx.setAttr(dn, attr.Type, attr.Vals...)
+	}
+
+	// Create all resources.
+	for _, r := range user.Resources {
+		if err := tx.CreateResource(ctx, r); err != nil {
+			return err
+		}
+	}
+
+	return nil
+}
+
 // GetUser returns a user.
 func (tx *backendTX) GetUser(ctx context.Context, username string) (*accountserver.User, error) {
 	// First of all, find the main user object, and just that one.
@@ -329,6 +367,21 @@ func (tx *backendTX) GetResource(ctx context.Context, rsrcID accountserver.Resou
 	return tx.backend.resources.FromLDAPWithType(rsrcID.Type(), result.Entries[0])
 }
 
+// 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
+	}
+
+	tx.create(dn)
+	for _, attr := range tx.backend.resources.ToLDAP(r) {
+		tx.setAttr(dn, attr.Type, attr.Vals...)
+	}
+
+	return nil
+}
+
 // 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)
diff --git a/backend/tx.go b/backend/tx.go
index 98f6e8d9..85a8fae9 100644
--- a/backend/tx.go
+++ b/backend/tx.go
@@ -42,13 +42,15 @@ 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),
+		conn:   conn,
+		cache:  make(map[string][]string),
+		newDNs: make(map[string]struct{}),
 	}
 }
 
@@ -72,6 +74,12 @@ func (tx *ldapTX) search(ctx context.Context, req *ldap.SearchRequest) (*ldap.Se
 	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) {
@@ -85,33 +93,67 @@ func (tx *ldapTX) setAttr(dn, attr string, values ...string) {
 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).
-	var dns []string
-	mods := make(map[string]*ldap.ModifyRequest)
-
-	for _, c := range tx.changes {
-		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)
-	}
+	adds, mods, dns := tx.aggregateChanges(ctx)
 
-	// Now issue all ModifyRequests, one by one. Abort on the first error.
+	// 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 {
-		mr := mods[dn]
-		if isEmptyModifyRequest(mr) {
-			continue
+		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)
 		}
-		log.Printf("issuing ModifyRequest: %+v", mr)
-		if err := tx.conn.Modify(ctx, mr); err != nil {
+		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)]
 
@@ -161,6 +203,10 @@ func isEmptyModifyRequest(mr *ldap.ModifyRequest) bool {
 		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) {
diff --git a/integrationtest/integration_test.go b/integrationtest/integration_test.go
index 0e83f75e..892d7e5a 100644
--- a/integrationtest/integration_test.go
+++ b/integrationtest/integration_test.go
@@ -230,3 +230,29 @@ func TestIntegration_ChangeUserPassword_SetsEncryptionKeys(t *testing.T) {
 		}
 	}
 }
+
+func TestIntegration_CreateResource(t *testing.T) {
+	stop, c := startService(t)
+	defer stop()
+
+	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", "example2.com"),
+				Name:          "example2.com",
+				Status:        accountserver.ResourceStatusActive,
+				Shard:         "host2",
+				OriginalShard: "host2",
+				Website: &accountserver.Website{
+					URL:          "https://example2.com",
+					DocumentRoot: "/home/sites/example2.com",
+					AcceptMail:   true,
+				},
+			},
+		},
+	}, nil)
+	if err != nil {
+		t.Fatal("CreateResourcesRequest", err)
+	}
+}
diff --git a/server/server.go b/server/server.go
index f4243d46..14084715 100644
--- a/server/server.go
+++ b/server/server.go
@@ -86,6 +86,13 @@ func (s *AccountServer) handleDisableResource(tx as.TX, w http.ResponseWriter, r
 	})
 }
 
+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(tx as.TX, w http.ResponseWriter, r *http.Request) (interface{}, error) {
 	var req as.MoveResourceRequest
 	return handleJSON(w, r, &req, func(ctx context.Context) (interface{}, error) {
@@ -143,6 +150,7 @@ func (s *AccountServer) Handler() http.Handler {
 	h.HandleFunc("/api/app_specific_password/delete", 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
diff --git a/service.go b/service.go
index c0098fcf..7c59298d 100644
--- a/service.go
+++ b/service.go
@@ -38,9 +38,12 @@ type TX interface {
 
 	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)
@@ -50,8 +53,6 @@ type TX interface {
 	DeleteApplicationSpecificPassword(context.Context, *User, string) error
 	SetUserTOTPSecret(context.Context, *User, string) error
 	DeleteUserTOTPSecret(context.Context, *User) error
-
-	HasAnyResource(context.Context, []FindResourceRequest) (bool, error)
 }
 
 // FindResourceRequest contains parameters for searching a resource by name.
@@ -72,6 +73,7 @@ type AccountService struct {
 
 	fieldValidators   *fieldValidators
 	resourceValidator *resourceValidator
+	userValidator     UserValidatorFunc
 }
 
 // NewAccountService builds a new AccountService with the specified configuration.
@@ -99,6 +101,7 @@ func newAccountServiceWithSSO(backend Backend, config *Config, ssoValidator sso.
 	}
 	s.fieldValidators = newFieldValidators(vc)
 	s.resourceValidator = newResourceValidator(vc)
+	s.userValidator = vc.validUser()
 
 	return s, nil
 }
diff --git a/validators.go b/validators.go
index 9e3bbfd3..e07de185 100644
--- a/validators.go
+++ b/validators.go
@@ -413,3 +413,13 @@ func newFieldValidators(v *validationContext) *fieldValidators {
 		email:    v.validHostedEmail(),
 	}
 }
+
+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)
+	}
+}
-- 
GitLab