Commit 6e9218bf authored by ale's avatar ale

Add code for writing changed resources to LDAP

parent a614e880
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
}
......@@ -17,6 +17,8 @@ import (
// testing.
type ldapConn interface {
Search(context.Context, *ldap.SearchRequest) (*ldap.SearchResult, error)
Add(context.Context, *ldap.AddRequest) error
Modify(context.Context, *ldap.ModifyRequest) error
Close()
}
......@@ -25,6 +27,7 @@ type LDAPBackend struct {
conn ldapConn
userQuery *queryConfig
userResourceQueries []*queryConfig
resourceQueries map[string]*queryConfig
}
// NewLDAPBackend initializes an LDAPBackend object with the given LDAP
......@@ -49,6 +52,33 @@ func NewLDAPBackend(pool *ldaputil.ConnectionPool, base string) *LDAPBackend {
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",
}),
},
}
}
......@@ -115,6 +145,13 @@ func s2b(s string) bool {
}
}
func b2s(b bool) string {
if b {
return "yes"
}
return "no"
}
func newResourceFromLDAP(entry *ldap.Entry, resourceType, nameAttr string) *accountserver.Resource {
name := entry.GetAttributeValue(nameAttr)
return &accountserver.Resource{
......@@ -127,6 +164,44 @@ func newResourceFromLDAP(entry *ldap.Entry, resourceType, nameAttr string) *acco
}
}
// 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 {
if s == "" {
return nil
}
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{
......@@ -136,6 +211,15 @@ func newEmailResource(entry *ldap.Entry) (*accountserver.Resource, error) {
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{
......@@ -145,6 +229,15 @@ func newMailingListResource(entry *ldap.Entry) (*accountserver.Resource, error)
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{
......@@ -153,6 +246,14 @@ func newWebDAVResource(entry *ldap.Entry) (*accountserver.Resource, error) {
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") {
......@@ -174,6 +275,25 @@ func newWebsiteResource(entry *ldap.Entry) (*accountserver.Resource, error) {
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{
......@@ -191,6 +311,15 @@ func newDatabaseResource(entry *ldap.Entry) (*accountserver.Resource, error) {
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"),
......@@ -251,6 +380,64 @@ func (b *LDAPBackend) GetUser(ctx context.Context, username string) (*accountser
return user, nil
}
func parseResourceID(resourceID string) (string, string) {
parts := strings.SplitN(resourceID, "/", 2)
return parts[0], parts[1]
}
// GetResource returns a ResourceWrapper, as part of a read-modify-update transaction.
func (b *LDAPBackend) GetResource(ctx context.Context, username, resourceID string) (*accountserver.Resource, error) {
resourceType, resourceName := parseResourceID(resourceID)
query, ok := b.resourceQueries[resourceType]
if !ok {
return nil, errors.New("unsupported resource type")
}
result, err := b.conn.Search(ctx, query.searchRequest(map[string]string{
"user": username,
"resource": resourceName,
"type": resourceType,
}, nil))
if err != nil {
if ldap.IsErrorWithCode(err, ldap.LDAPResultNoSuchObject) {
return nil, nil
}
return nil, err
}
r, err := parseLdapResource(result.Entries[0])
if err != nil {
return nil, err
}
r.SetBackendHandle(&ldapObjectData{
dn: result.Entries[0].DN,
original: r.Copy(),
})
return r, nil
}
// UpdateResource updates a LDAP-backed resource that was obtained by a previous GetResource call.
func (b *LDAPBackend) UpdateResource(ctx context.Context, username string, r *accountserver.Resource) error {
lo, ok := r.GetBackendHandle().(*ldapObjectData)
if !ok || lo == nil {
return errors.New("resource did not come from GetResource")
}
modRequest := createModifyRequest(lo.dn, lo.original, r)
if modRequest == nil {
return nil
}
return b.conn.Modify(ctx, modRequest)
}
type ldapObjectData struct {
dn string
original *accountserver.Resource
}
func parseLdapResource(entry *ldap.Entry) (*accountserver.Resource, error) {
switch {
case isObjectClass(entry, "virtualMailUser"):
......
package backend
import (
"reflect"
"testing"
"git.autistici.org/ai3/accountserver"
ldap "gopkg.in/ldap.v2"
)
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"},
},
)
r, err := parseLdapResource(entry)
if err != nil {
t.Fatal(err)
}
expected := &accountserver.Resource{
ID: "email/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",
},
}
if !reflect.DeepEqual(r, expected) {
t.Fatalf("bad result: got %+v, expected %+v", r, expected)
}
}
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"},
},
)
r, err := parseLdapResource(entry)
if err != nil {
t.Fatal(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)
}
r2.Email.Aliases = nil
mod = createModifyRequest("dn", r, r2)
if len(mod.DeleteAttributes) != 1 {
t.Fatalf("bad ModifyRequest after deleting aliases: %+v", mod)
}
}
......@@ -5,6 +5,7 @@ import (
"log"
"net/http"
"git.autistici.org/ai/go-sso"
"git.autistici.org/ai3/go-common/serverutil"
"git.autistici.org/ai3/accountserver"
......@@ -16,11 +17,49 @@ type Backend interface {
}
type AccountServer struct {
backend Backend
backend Backend
validator sso.Validator
}
func New(backend Backend) *AccountServer {
return &AccountServer{backend: backend}
func New(backend Backend, ssoPublicKey []byte, ssoDomain string) (*AccountServer, error) {
v, err := sso.NewValidator(ssoPublicKey, ssoDomain)
if err != nil {
return nil, err
}
return &AccountServer{
backend: backend,
validator: v,
}, nil
}
var adminGroup = "admins"
func isAdmin(tkt *sso.Ticket) bool {
for _, g := range tkt.Groups {
if g == adminGroup {
return true
}
}
return false
}
func (s *AccountServer) authorize(w http.ResponseWriter, ssoToken, username string) bool {
tkt, err := s.validator.Validate(ssoToken, "", s.ssoService, s.ssoGroups)
if err != nil {
log.Printf("authentication error: %v", err)
http.Error(w, err.Error(), http.StatusUnauthorized)
return false
}
// Requests are allowed if the SSO ticket corresponds to an admin, or if
// it identifies the same user that we're querying.
if !isAdmin(tkt) && tkt.User != username {
log.Printf("unauthorized access to %s from %s", username, tkt.User)
http.Error(w, err.Error(), http.StatusUnauthorized)
return false
}
return true
}
func (s *AccountServer) handleGetUser(w http.ResponseWriter, r *http.Request) {
......@@ -28,6 +67,9 @@ func (s *AccountServer) handleGetUser(w http.ResponseWriter, r *http.Request) {
if !serverutil.DecodeJSONRequest(w, r, &req) {
return
}
if !s.authorize(w, req.SSO, req.Username) {
return
}
user, err := s.backend.GetUser(r.Context(), req.Username)
if err != nil {
......
......@@ -89,6 +89,46 @@ 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{}
}
// SetBackendHandle associates some backend-specific data at runtime
// with this resource.
func (r *Resource) SetBackendHandle(h interface{}) {
r.opaque = h
}
// GetBackendHandle returns the backend-specific data associated with
// the resource.
func (r *Resource) GetBackendHandle() interface{} {
return r.opaque
}
// Copy the resource (makes a deep copy).
func (r *Resource) Copy() *Resource {
rr := *r
switch {
case r.Email != nil:
e := *r.Email
rr.Email = &e
case r.Website != nil:
w := *r.Website
rr.Website = &w
case r.List != nil:
l := *r.List
rr.List = &l
case r.DAV != nil:
d := *r.DAV
rr.DAV = &d
case r.Database != nil:
d := *r.Database
rr.Database = &d
}
return &rr
}
// Email resource attributes.
......@@ -155,6 +195,7 @@ type Blog struct {
// RPC requests.
type GetUserRequest struct {
SSO string `json:"sso"`
Username string `json:"username"`
}
......
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