Skip to content
Snippets Groups Projects
Commit 6e9218bf authored by ale's avatar ale
Browse files

Add code for writing changed resources to LDAP

parent a614e880
No related branches found
No related tags found
No related merge requests found
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"
......@@ -17,10 +18,48 @@ type Backend interface {
type AccountServer struct {
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"`
}
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment