Commit 955d31f0 authored by ale's avatar ale

Refactor user resource discovery

Add a couple of interfaces to run arbitrary (including non-LDAP)
queries to discover a user's Resources.

The first implementation (beyond the previous behavior using a
statically templated LDAP query) is the mailing list discoverer, which
can find lists owned by an email alias, thus fixing issue #8.
parent 0a2b51d8
Pipeline #3329 passed with stages
in 5 minutes and 18 seconds
package ldapbackend
import (
"bytes"
"context"
"fmt"
"math/rand"
......@@ -30,6 +31,11 @@ const (
uidNumberLDAPAttr = "uidNumber"
)
// Interface to something that adds Resources to a User.
type userResourceFinder interface {
GetResources(context.Context, *backendTX, *as.RawUser) ([]*as.Resource, error)
}
// backend is the interface to an LDAP-backed user database.
//
// We keep a set of LDAP queries for each resource type, each having a
......@@ -41,7 +47,7 @@ type backend struct {
baseDN string
userQuery *queryTemplate
searchUserQuery *queryTemplate
userResourceQueries []*queryTemplate
userResourceQueries []userResourceFinder
resources *resourceRegistry
// Range for new user IDs.
......@@ -95,18 +101,17 @@ func newLDAPBackendWithConn(conn ldapConn, baseDN string) (*backend, error) {
Filter: "(uid=${pattern}*)",
Scope: ldap.ScopeSingleLevel,
},
userResourceQueries: []*queryTemplate{
userResourceQueries: []userResourceFinder{
// Find all resources that are children of the main uid object.
&queryTemplate{
newTemplateLDAPResourceFinder(&queryTemplate{
Base: joinDN("uid=${user}", "ou=People", baseDN),
Scope: ldap.ScopeWholeSubtree,
},
// Find mailing lists, which are nested under a different root.
&queryTemplate{
Base: joinDN("ou=Lists", baseDN),
Filter: "(&(objectClass=mailingList)(listOwner=${user}))",
Scope: ldap.ScopeSingleLevel,
},
}),
// Find mailing lists, which are nested under a
// different root. This must be run after having
// discovered the base resources, so we can use the list
// of email aliases as listOwners.
newMailingListResourceFinder(baseDN),
},
resources: reg,
minUID: defaultMinUID,
......@@ -127,11 +132,11 @@ func newUser(entry *ldap.Entry) (*as.RawUser, error) {
uidNumber, _ := strconv.Atoi(entry.GetAttributeValue(uidNumberLDAPAttr)) // nolint
user := &as.RawUser{
User: as.User{
Name: entry.GetAttributeValue("uid"),
Lang: entry.GetAttributeValue(preferredLanguageLDAPAttr),
UID: uidNumber,
Status: entry.GetAttributeValue("status"),
Shard: entry.GetAttributeValue("host"),
Name: entry.GetAttributeValue("uid"),
Lang: entry.GetAttributeValue(preferredLanguageLDAPAttr),
UID: uidNumber,
Status: entry.GetAttributeValue("status"),
Shard: entry.GetAttributeValue("host"),
LastPasswordChangeStamp: decodeShadowTimestamp(entry.GetAttributeValue(passwordLastChangeLDAPAttr)),
AccountRecoveryHint: entry.GetAttributeValue(recoveryHintLDAPAttr),
U2FRegistrations: decodeU2FRegistrations(entry.GetAttributeValues(u2fRegistrationsLDAPAttr)),
......@@ -211,6 +216,96 @@ func (tx *backendTX) UpdateUser(ctx context.Context, user *as.User) error {
return nil
}
type ldapQuery interface {
query(*as.RawUser) *ldap.SearchRequest
}
// Find resources for a user using a LDAP queryTemplate.
type ldapTemplateQuery struct {
*queryTemplate
}
func (t *ldapTemplateQuery) query(user *as.RawUser) *ldap.SearchRequest {
return t.queryTemplate.query(templateVars{"user": user.Name})
}
func newTemplateLDAPResourceFinder(tpl *queryTemplate) userResourceFinder {
return &ldapUserResourceFinder{&ldapTemplateQuery{tpl}}
}
// Find mailing lists using a dynamic LDAP query with all the user aliases.
type mailingListResourceFinder struct {
baseDN string
}
func (m *mailingListResourceFinder) query(user *as.RawUser) *ldap.SearchRequest {
// Build the LDAP query filter with all the user aliases.
var b bytes.Buffer
fmt.Fprintf(&b, "(&(objectClass=mailingList)(|(")
for i, addr := range user.AllEmailAddrs() {
if i > 0 {
fmt.Fprintf(&b, ")(")
}
fmt.Fprintf(&b, "listOwner=%s", ldap.EscapeFilter(addr))
}
fmt.Fprintf(&b, ")))")
return ldap.NewSearchRequest(
m.baseDN,
ldap.ScopeSingleLevel,
ldap.NeverDerefAliases,
0,
0,
false,
b.String(),
nil,
nil,
)
}
func newMailingListResourceFinder(baseDN string) userResourceFinder {
return &ldapUserResourceFinder{&mailingListResourceFinder{joinDN("ou=Lists", baseDN)}}
}
// Find resources for a user using a generic LDAP query.
type ldapUserResourceFinder struct {
ldapQuery
}
func (f *ldapUserResourceFinder) GetResources(ctx context.Context, tx *backendTX, user *as.RawUser) ([]*as.Resource, error) {
result, err := tx.search(ctx, f.ldapQuery.query(user))
if err != nil {
return nil, err
}
var resources []*as.Resource
for _, entry := range result.Entries {
// Some user-level attributes are actually stored on the email
// object, which is desired in some cases, but in others is a
// shortcoming of the legacy A/I database model. Set them on the
// main User object. For the latter, attributes on the main User
// object take precedence.
if isObjectClass(entry, "virtualMailUser") {
if user.AccountRecoveryHint == "" {
if s := entry.GetAttributeValue(recoveryHintLDAPAttr); s != "" {
user.AccountRecoveryHint = s
}
}
user.Keys = decodeUserEncryptionKeys(
entry.GetAttributeValues(storagePrivateKeyLDAPAttr))
user.HasEncryptionKeys = (entry.GetAttributeValue(storagePublicKeyLDAPAttr) != "")
}
// Parse the resource.
if r, err := tx.backend.resources.FromLDAP(entry); err == nil {
resources = append(resources, r)
}
}
return resources, nil
}
// GetUser returns a user.
func (tx *backendTX) GetUser(ctx context.Context, username string) (*as.RawUser, error) {
// First of all, find the main user object, and just that one.
......@@ -235,35 +330,9 @@ func (tx *backendTX) GetUser(ctx context.Context, username string) (*as.RawUser,
// object we just created.
// TODO: parallelize.
// TODO: add support for non-LDAP resource queries.
for _, tpl := range tx.backend.userResourceQueries {
result, err = tx.search(ctx, tpl.query(vars))
if err != nil {
continue
}
for _, entry := range result.Entries {
// Some user-level attributes are actually stored on
// the email object, which is desired in some cases,
// but in others is a shortcoming of the legacy A/I
// database model. Set them on the main User
// object. For the latter, attributes on the main User
// object take precedence.
if isObjectClass(entry, "virtualMailUser") {
if user.AccountRecoveryHint == "" {
if s := entry.GetAttributeValue(recoveryHintLDAPAttr); s != "" {
user.AccountRecoveryHint = s
}
}
user.Keys = decodeUserEncryptionKeys(
entry.GetAttributeValues(storagePrivateKeyLDAPAttr))
user.HasEncryptionKeys = (entry.GetAttributeValue(storagePublicKeyLDAPAttr) != "")
}
// Parse the resource and add it to the User.
if r, err := tx.backend.resources.FromLDAP(entry); err == nil {
user.Resources = append(user.Resources, r)
}
for _, f := range tx.backend.userResourceQueries {
if resources, err := f.GetResources(ctx, tx, user); err == nil {
user.Resources = append(user.Resources, resources...)
}
}
......
......@@ -17,8 +17,9 @@ const (
testLDAPPort = 42871
testLDAPAddr = "ldap://127.0.0.1:42871"
testUser1 = "uno@investici.org"
testUser2 = "due@investici.org" // has encryption keys
testUser3 = "tre@investici.org" // has OTP
testUser2 = "due@investici.org" // has encryption keys
testUser3 = "tre@investici.org" // has OTP
testUser4 = "quattro@investici.org" // has mailing lists
testBaseDN = "dc=example,dc=com"
)
......@@ -34,6 +35,10 @@ func startServerAndGetUser3(t testing.TB) (func(), as.Backend, *as.RawUser) {
return startServerAndGetUserWithName(t, testUser3)
}
func startServerAndGetUser4(t testing.TB) (func(), as.Backend, *as.RawUser) {
return startServerAndGetUserWithName(t, testUser4)
}
func startServer(t testing.TB) (func(), as.Backend) {
stop := ldaptest.StartServer(t, &ldaptest.Config{
Dir: "../../ldaptest",
......@@ -44,6 +49,7 @@ func startServer(t testing.TB) (func(), as.Backend) {
"testdata/test1.ldif",
"testdata/test2.ldif",
"testdata/test3.ldif",
"testdata/test4.ldif",
},
})
......@@ -141,6 +147,11 @@ func TestModel_GetUser_Resources(t *testing.T) {
stop, b, user := startServerAndGetUser(t)
defer stop()
// Ensure that the user *has* resources.
if len(user.Resources) < 1 {
t.Fatal("test user has no resources!")
}
// Fetch individually all user resources, one by one, and
// check that they match what we have already.
tx2, _ := b.NewTransaction()
......@@ -164,6 +175,18 @@ func TestModel_GetUser_Resources(t *testing.T) {
}
}
func TestModel_GetUser_MailingLists(t *testing.T) {
stop, _, user := startServerAndGetUser4(t)
defer stop()
// Ensure that the user has the expected number of list resources.
// The backend should find two lists, one of which has an alias as the owner.
lists := user.GetResourcesByType(as.ResourceTypeMailingList)
if l := len(lists); l != 2 {
t.Fatalf("test user has %d mailing lists, expected %d", l, 2)
}
}
func TestModel_SearchUser(t *testing.T) {
stop, b := startServer(t)
defer stop()
......
dn: uid=quattro@investici.org,ou=People,dc=example,dc=com
cn: quattro@investici.org
gecos: quattro@investici.org
gidNumber: 2000
givenName: Private
homeDirectory: /var/empty
loginShell: /bin/false
objectClass: top
objectClass: person
objectClass: posixAccount
objectClass: shadowAccount
objectClass: organizationalPerson
objectClass: inetOrgPerson
objectClass: investiciUser
shadowLastChange: 12345
shadowMax: 99999
shadowWarning: 7
sn: quattro@investici.org
uid: quattro@investici.org
uidNumber: 23801
userPassword:: JGEyJDQkMzI3NjgkMSQwZDgyMzU1YjQ0Mzg0M2NmZDY4MjU1MzE4ZTVjYTdiZSRmNTQ0ODkxOTFiNWZlYzk2MDRlNWQ2ODZjMDQxZjJkNTFmOTgxOGY4ZTFmM2E4MDYzY2U3ZTEwMTE3OTc2OGI0
totpSecret: ABCDEF
dn: mail=quattro@investici.org,uid=quattro@investici.org,ou=People,dc=example,dc=com
creationDate: 01-08-2013
gidNumber: 2000
host: host2
mail: quattro@investici.org
mailAlternateAddress: quattroalias@investici.org
mailMessageStore: investici.org/quattro/
objectClass: top
objectClass: virtualMailUser
originalHost: host2
status: active
uidNumber: 23801
userPassword:: JGEyJDQkMzI3NjgkMSQwZDgyMzU1YjQ0Mzg0M2NmZDY4MjU1MzE4ZTVjYTdiZSRmNTQ0ODkxOTFiNWZlYzk2MDRlNWQ2ODZjMDQxZjJkNTFmOTgxOGY4ZTFmM2E4MDYzY2U3ZTEwMTE3OTc2OGI0
dn: listName=list1@investici.org,ou=Lists,dc=example,dc=com
objectClass: mailingList
objectClass: top
listName: list@investici.org
listDescription: Mailing list 1
public: no
status: active
listOwner: quattro@investici.org
host: host2
originalHost: host2
dn: listName=list2@investici.org,ou=Lists,dc=example,dc=com
objectClass: mailingList
objectClass: top
listName: list@investici.org
listDescription: Mailing list 2
public: no
status: active
listOwner: quattroalias@investici.org
host: host2
originalHost: host2
......@@ -115,6 +115,21 @@ func (u *User) GetResourcesByGroup(group string) []*Resource {
return out
}
// AllEmailAddrs is a convenience function that returns all
// (non-inactive) email addresses for this User.
func (u *User) AllEmailAddrs() []string {
var addrs []string
for _, r := range u.Resources {
if r.Type == ResourceTypeEmail && r.Status != ResourceStatusInactive {
addrs = append(addrs, r.Name)
if r.Email != nil && len(r.Email.Aliases) > 0 {
addrs = append(addrs, r.Email.Aliases...)
}
}
}
return addrs
}
// RawUser extends User with private information (as stored in the
// database) that we have a direct use for.
//
......
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