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