Commit 02d7c9c6 authored by ale's avatar ale

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.
parent ac2aa256
......@@ -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
......
......@@ -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)
}
......
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
}
......@@ -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"