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
}
This diff is collapsed.
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")
}
}
This diff is collapsed.
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)
}
}
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
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
......@@ -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
}
......
......@@ -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"`
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment