Commit ac2aa256 authored by ale's avatar ale

Implement a transaction-like interface for the backend

This should make it easier to implement a SQL backend in the future if
necessary, even though LDAP knows no such thing as transactions.

As a result of a better low-level interface, reducing the boilerplate
LDAP code, the business logic in model.go should be quite more
readable.
parent b299bbd7
This diff is collapsed.
This diff is collapsed.
......@@ -12,8 +12,6 @@ import (
func resourcesEqual(a, b *accountserver.Resource) bool {
aa := *a
bb := *b
aa.Opaque = nil
bb.Opaque = nil
return reflect.DeepEqual(aa, bb)
}
......@@ -37,7 +35,7 @@ func TestEmailResource_FromLDAP(t *testing.T) {
}
expected := &accountserver.Resource{
ID: "email/test@investici.org",
ID: accountserver.NewResourceID("email", "test@investici.org", "test@investici.org"),
Name: "test@investici.org",
Type: accountserver.ResourceTypeEmail,
Status: "active",
......@@ -86,3 +84,17 @@ func TestEmailResource_Diff(t *testing.T) {
t.Fatalf("bad ModifyRequest after deleting aliases: %+v", mod)
}
}
func TestConn(t *testing.T) {
stop := startTestLDAPServer(t, &testLDAPServerConfig{
Port: 42781,
Base: "dc=example,dc=com",
LDIFs: []string{"testdata/base.ldif"},
})
defer stop()
_, err := NewLDAPBackend("ldap://127.0.0.1:42781", "cn=manager,dc=example,dc=com", "password", "dc=example,dc=com")
if err != nil {
t.Fatal("NewLDAPBackend", err)
}
}
dn: dc=example,dc=org
objectclass: domain
objectclass: top
dc: example
dn: ou=people,dc=example,dc=org
objectclass: top
objectclass: organizationalUnit
ou: people
dn: uid=test@investici.org,ou=people,dc=example,dc=org
objectclass: top
objectclass: person
objectclass: organizationalPerson
objectclass: inetOrgPerson
cn: test@investici.org
sn: test@investici.org
uid: test@investici.org
userPassword: koala
package backend
import (
"context"
"strings"
"gopkg.in/ldap.v2"
)
type ldapAttr struct {
dn, attr string
values []string
}
// An LDAP "transaction" is really just a buffer of attribute changes,
// that are all executed at once at Commit() time.
//
// Unfortunately, in order to issue LDAP Modify requests properly
// (which have separate Add and Replace options, for one), we need to
// keep state about the observed data, so we cache the results of all
// Search operations and compare those with the new data at commit
// time. If you attempt to modify an object that you haven't Searched
// for previously in the same transaction, you're most likely to get a
// LDAP error in return. Which is fine because all our workflows are
// read/modify/update ones anyway.
//
// Since ordering of Modify requests is important in LDAP, this object
// will preserve the ordering of DNs and attributes when calling
// Commit().
//
type ldapTX struct {
conn ldapConn
cache map[string][]string
changes []ldapAttr
}
func newLDAPTX(conn ldapConn) *ldapTX {
return &ldapTX{
conn: conn,
cache: make(map[string][]string),
}
}
func cacheKey(dn, attr string) string {
return strings.Join([]string{dn, attr}, ";")
}
// Search wrapper that fills the cache.
func (tx *ldapTX) search(ctx context.Context, req *ldap.SearchRequest) (*ldap.SearchResult, error) {
res, err := tx.conn.Search(ctx, req)
if err != nil {
return nil, err
}
for _, entry := range res.Entries {
for _, attr := range entry.Attributes {
tx.cache[cacheKey(entry.DN, attr.Name)] = attr.Values
}
}
return res, nil
}
// 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) {
tx.changes = append(tx.changes, ldapAttr{dn: dn, attr: attr, values: values})
}
// Commit the transaction, sending all changes to the LDAP server.
func (tx *ldapTX) Commit(ctx context.Context) error {
// Iterate through the changes, and generate ModifyRequest
// objects grouped by DN (while preserving the order of DNs).
var dns []string
mods := make(map[string]*ldap.ModifyRequest)
for _, c := range tx.changes {
mr, ok := mods[c.dn]
if !ok {
mr = ldap.NewModifyRequest(c.dn)
mods[c.dn] = mr
dns = append(dns, c.dn)
}
tx.updateModifyRequest(mr, c)
}
// Now issue all ModifyRequests, one by one. Abort on the first error.
for _, dn := range dns {
mr := mods[dn]
if isEmptyModifyRequest(mr) {
continue
}
if err := tx.conn.Modify(ctx, mr); err != nil {
return err
}
}
return nil
}
func (tx *ldapTX) updateModifyRequest(mr *ldap.ModifyRequest, attr ldapAttr) {
old, ok := tx.cache[cacheKey(attr.dn, attr.attr)]
switch {
case ok && !stringListEquals(old, attr.values):
mr.Replace(attr.attr, attr.values)
case ok && attr.values == nil:
mr.Delete(attr.attr, nil)
case !ok && len(attr.values) > 0:
mr.Add(attr.attr, attr.values)
}
}
func isEmptyModifyRequest(mr *ldap.ModifyRequest) bool {
return (len(mr.AddAttributes) == 0 &&
len(mr.DeleteAttributes) == 0 &&
len(mr.ReplaceAttributes) == 0)
}
// Unordered list comparison.
func stringListEquals(a, b []string) bool {
if len(a) != len(b) {
return false
}
tmp := make(map[string]struct{})
for _, aa := range a {
tmp[aa] = struct{}{}
}
for _, bb := range b {
if _, ok := tmp[bb]; !ok {
return false
}
}
return true
}
package accountserver
import (
"encoding/json"
"errors"
"fmt"
"net/url"
"path/filepath"
"strings"
"time"
)
......@@ -29,8 +33,6 @@ type User struct {
AppSpecificPasswords []*AppSpecificPasswordInfo `json:"app_specific_passwords,omitempty"`
Resources []*Resource `json:"resources,omitempty"`
Opaque interface{}
}
func (u *User) GetResourcesByType(resourceType string) []*Resource {
......@@ -118,6 +120,85 @@ const (
ResourceStatusInactive = "inactive"
)
type ResourceID struct {
Parts []string
}
func NewResourceID(p ...string) ResourceID {
return ResourceID{Parts: p}
}
func (i ResourceID) Empty() bool {
return len(i.Parts) == 0
}
// Type of the resource, the first component.
func (i ResourceID) Type() string {
return i.Parts[0]
}
// Name of the resource, without path. This is the last component of
// the composite ID.
func (i ResourceID) Name() string {
return i.Parts[len(i.Parts)-1]
}
// User that owns the resource. This may be missing for some resources
// (those that share a single global namespace), in which case this
// function will return an empty string.
func (i ResourceID) User() string {
if len(i.Parts) > 2 {
return i.Parts[1]
}
return ""
}
// Path for the resource, i.e. the ID components after type and
// (optionally) user.
func (i ResourceID) Path() []string {
if len(i.Parts) > 2 {
return i.Parts[2:]
}
return i.Parts[1:]
}
func (i ResourceID) String() string {
var tmp []string
for _, s := range i.Parts {
tmp = append(tmp, url.PathEscape(s))
}
return filepath.Join(tmp...)
}
func (i ResourceID) MarshalJSON() ([]byte, error) {
return json.Marshal(i.String())
}
func (i *ResourceID) UnmarshalJSON(data []byte) error {
var s string
err := json.Unmarshal(data, &s)
if err != nil {
return err
}
*i, err = ParseResourceID(s)
return err
}
func ParseResourceID(s string) (ResourceID, error) {
var id ResourceID
for _, e := range strings.Split(s, "/") {
u, err := url.PathUnescape(e)
if err != nil {
return ResourceID{}, err
}
id.Parts = append(id.Parts, u)
}
if len(id.Parts) < 2 {
return ResourceID{}, errors.New("malformed resource ID")
}
return id, nil
}
// Resource represents a somewhat arbitrary resource, identified by a
// unique name/type combination (a.k.a. its ID). A resource contains
// some common properties related to sharding and state, plus
......@@ -125,17 +206,16 @@ const (
type Resource struct {
// ID is a unique primary key in the resources space,
// consisting of 'type/name'.
ID string `json:"id"`
ID ResourceID `json:"id"`
// Name of the resource, unique to the resource type namespace
// for this user.
// 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 string `json:"parent_id,omitempty"`
ParentID ResourceID `json:"parent_id,omitempty"`
// Optional attribute for resources that have a status.
Status string `json:"status,omitempty"`
......@@ -157,11 +237,6 @@ type Resource struct {
Website *Website `json:"website,omitempty"`
DAV *WebDAV `json:"dav,omitempty"`
Database *Database `json:"database,omitempty"`
// When the resource is used internally in the accountserver,
// it needs a reference to backend-specific data. This is not
// part of the public interface, and it is not serialized.
Opaque interface{} `json:"-"`
}
// Copy the resource (makes a deep copy).
......
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