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