Browse code

add ldap groups sync

Steve Kuznetsov authored on 2015/09/17 23:08:31
Showing 24 changed files
... ...
@@ -39,7 +39,7 @@ OpenShift `Group`'s `Labels` will also be used to store metadata regarding the s
39 39
   * Populate new OpenShift `Group`'s `Users` from resulting OpenShift `UserIdentityMappings`, update OpenShift `Group` metadata fields tied to LDAP attributes, leave other OpenShift `Group` fields unchanged
40 40
 
41 41
 ##  Determining Ordered List of LDAP Groups To Sync
42
-An `LDAPGroupLister` determines what LDAP groups needs to be synced and outputs the result as a set of unique identifier strings (called "LDAP group UIDs" in this document). The other objects that take LDAP group UIDs need to understand the format of this string (e.g. these objects will be tightly coupled). For example, an `LDAPGroupLister` for schema 1 above cannot be used with a `LDAPGroupDataExtractor` for schema 2. The four `LDAPGroupLister` implementations that are necessary are:
42
+An `LDAPGroupLister` determines what LDAP groups needs to be synced and outputs the result as a set of unique identifier strings (called "LDAP group UIDs" in this document). The other objects that take LDAP group UIDs need to understand the format of this string (e.g. these objects will be tightly coupled). For example, an `LDAPGroupLister` for schema 1 above cannot be used with a `LDAPGroupMemberExtractor` for schema 2. The four `LDAPGroupLister` implementations that are necessary are:
43 43
 
44 44
 1. List LDAP group UIDs for all OpenShift `Groups` matching a `Label` selector identifying them as pertaining to the sync job, minus a blacklist
45 45
 * List LDAP group UIDs for some whitelist of OpenShift `Groups`
... ...
@@ -48,7 +48,7 @@ An `LDAPGroupLister` determines what LDAP groups needs to be synced and outputs
48 48
 
49 49
 ```go
50 50
 // LDAPGroupLister lists the LDAP groups that need to be synced by a job. The LDAPGroupLister needs to
51
-// be paired with an LDAPGroupDataExtractor that understands the format of the unique identifiers
51
+// be paired with an LDAPGroupMemberExtractor that understands the format of the unique identifiers
52 52
 // returned to represent the LDAP groups to be synced.
53 53
 type LDAPGroupLister interface {
54 54
 	ListGroups() (groupUIDs []string, err error)
... ...
@@ -56,11 +56,11 @@ type LDAPGroupLister interface {
56 56
 ```
57 57
 
58 58
 ## Collecting LDAP Members and Metadata
59
-An `LDAPGroupDataExtractor` gathers information on an LDAP group based on an LDAP group UID. It may cache LDAP responses for responsivity. The approach to implementing this structure will vary with LDAP schema as well as sync job request.
59
+An `LDAPGroupMemberExtractor` gathers information on an LDAP group based on an LDAP group UID. It may cache LDAP responses for responsivity. The approach to implementing this structure will vary with LDAP schema as well as sync job request.
60 60
 
61 61
 ```go
62
-// LDAPGroupDataExtractor retrieves data about an LDAP group from the LDAP server.
63
-type LDAPGroupDataExtractor interface {
62
+// LDAPGroupMemberExtractor retrieves data about an LDAP group from the LDAP server.
63
+type LDAPGroupMemberExtractor interface {
64 64
 	// ExtractMembers returns the list of LDAP first-class user entries that are members of the LDAP
65 65
 	// group specified by the groupUID
66 66
 	ExtractMembers(groupUID string) (members []*ldap.Entry, err error)
... ...
@@ -68,7 +68,7 @@ type LDAPGroupDataExtractor interface {
68 68
 ```
69 69
 
70 70
 ## Determining OpenShift `User` Names for LDAP Members
71
-The mapping of a LDAP member entry to an OpenShift `User` Name will be deterministic and simple: whatever LDAP entry attribute is used for the OpenShift `User` Name field upon creation of OpenShift `Users` will be used as the OpenShift `User` Name. As long as the `DeterministicUserIdentityMapper` is used to introduce LDAP member entries to OpenShift `User` records and the `LDAPUserToIdentityMapping` used for the sync job and `DeterministicUserIdentityMapper` is the same, the mappings created by the `LDAPUserNameMapper` will be correct.
71
+The mapping of a LDAP member entry to an OpenShift `User` Name will be deterministic and simple: whatever LDAP entry attribute is used for the OpenShift `User` Name field upon creation of OpenShift `Users` will be used as the OpenShift `User` Name. As long as the `DeterministicUserIdentityMapper` is used to introduce LDAP member entries to OpenShift `User` records and the `LDAPUserAttributeDefiner` used for the sync job and `DeterministicUserIdentityMapper` is the same, the mappings created by the `LDAPUserNameMapper` will be correct.
72 72
 
73 73
 ```go
74 74
 // LDAPUserNameMapper maps an LDAP entry representing a user to the OpenShift User Name corresponding to it
... ...
@@ -22,11 +22,6 @@ type Options struct {
22 22
 	// ClientConfig holds information about connecting with the LDAP server
23 23
 	ClientConfig ldaputil.LDAPClientConfig
24 24
 
25
-	// BindDN is the optional username to bind to for the search phase. If specified, BindPassword must also be set.
26
-	BindDN string
27
-	// BindPassword is the optional password to bind to for the search phase.
28
-	BindPassword string
29
-
30 25
 	// UserAttributeDefiner defines the values corresponding to OpenShift Identities in LDAP entries
31 26
 	// by using a deterministic mapping of LDAP entry attributes to OpenShift Identity fields. The first
32 27
 	// attribute with a non-empty value is used for all but the latter identity field. If no LDAP attributes
... ...
@@ -88,18 +83,15 @@ func (a *Authenticator) getIdentity(username, password string) (authapi.UserIden
88 88
 		return nil, false, nil
89 89
 	}
90 90
 
91
-	// Make the connection
91
+	// Make the connection and bind to it if a bind DN and password were given
92 92
 	l, err := a.options.ClientConfig.Connect()
93 93
 	if err != nil {
94 94
 		return nil, false, err
95 95
 	}
96 96
 	defer l.Close()
97 97
 
98
-	// If specified, bind the username/password for search phase
99
-	if len(a.options.BindDN) > 0 {
100
-		if err := l.Bind(a.options.BindDN, a.options.BindPassword); err != nil {
101
-			return nil, false, err
102
-		}
98
+	if _, err := a.options.ClientConfig.Bind(l); err != nil {
99
+		return nil, false, err
103 100
 	}
104 101
 
105 102
 	// & together the filter specified in the LDAP options with the user-specific filter
... ...
@@ -5,25 +5,47 @@ import (
5 5
 	"fmt"
6 6
 	"net"
7 7
 
8
+	"k8s.io/kubernetes/pkg/util"
9
+
8 10
 	"github.com/go-ldap/ldap"
9 11
 )
10 12
 
11 13
 // NewLDAPClientConfig returns a new LDAPClientConfig
12
-func NewLDAPClientConfig(url LDAPURL, insecure bool, tlsConfig *tls.Config) LDAPClientConfig {
13
-	return LDAPClientConfig{
14
-		Scheme:    url.Scheme,
15
-		Host:      url.Host,
16
-		Insecure:  insecure,
17
-		TLSConfig: tlsConfig,
14
+func NewLDAPClientConfig(URL, bindDN, bindPassword, CA string, insecure bool) (LDAPClientConfig, error) {
15
+	url, err := ParseURL(URL)
16
+	if err != nil {
17
+		return LDAPClientConfig{}, fmt.Errorf("Error parsing URL: %v", err)
18 18
 	}
19
+
20
+	tlsConfig := &tls.Config{}
21
+	if len(CA) > 0 {
22
+		roots, err := util.CertPoolFromFile(CA)
23
+		if err != nil {
24
+			return LDAPClientConfig{}, fmt.Errorf("error loading cert pool from ca file %s: %v", CA, err)
25
+		}
26
+		tlsConfig.RootCAs = roots
27
+	}
28
+
29
+	return LDAPClientConfig{
30
+		Scheme:       url.Scheme,
31
+		Host:         url.Host,
32
+		BindDN:       bindDN,
33
+		BindPassword: bindPassword,
34
+		Insecure:     insecure,
35
+		TLSConfig:    tlsConfig,
36
+	}, nil
19 37
 }
20 38
 
21 39
 // LDAPClientConfig holds information for connecting to an LDAP server
22 40
 type LDAPClientConfig struct {
23
-	// Scheme is ldap or ldaps
41
+	// Scheme is the LDAP connection scheme, either ldap or ldaps
24 42
 	Scheme Scheme
25 43
 	// Host is the host:port of the LDAP server
26 44
 	Host string
45
+	// BindDN is an optional DN to bind with during the search phase.
46
+	BindDN string
47
+	// BindPassword is an optional password to bind with during the search phase.
48
+	BindPassword string
27 49
 	// Insecure specifies if TLS is required for the connection. If true, either an ldap://... URL or
28 50
 	// StartTLS must be supported by the server
29 51
 	Insecure bool
... ...
@@ -31,9 +53,9 @@ type LDAPClientConfig struct {
31 31
 	TLSConfig *tls.Config
32 32
 }
33 33
 
34
-// Connect returns an established LDAP connection, or an error if the connection could not be made
35
-// (or successfully upgraded to TLS). If no error is returned, the caller is responsible for closing
36
-// the connection
34
+// Connect returns an established LDAP connection, or an error if the connection could not
35
+// be made (or successfully upgraded to TLS). If no error is returned, the caller is responsible for
36
+// closing the connection
37 37
 func (l *LDAPClientConfig) Connect() (*ldap.Conn, error) {
38 38
 	tlsConfig := l.TLSConfig
39 39
 
... ...
@@ -78,3 +100,17 @@ func (l *LDAPClientConfig) Connect() (*ldap.Conn, error) {
78 78
 		return nil, fmt.Errorf("unsupported scheme %q", l.Scheme)
79 79
 	}
80 80
 }
81
+
82
+// Bind binds to a given LDAP connection if a bind DN and password were given.
83
+// Bind returns whether a bind occured and whether an error occurred
84
+func (l *LDAPClientConfig) Bind(connection *ldap.Conn) (bound bool, err error) {
85
+	if len(l.BindDN) > 0 {
86
+		if err := connection.Bind(l.BindDN, l.BindPassword); err != nil {
87
+			return false, err
88
+		} else {
89
+			return true, nil
90
+		}
91
+	}
92
+
93
+	return false, nil
94
+}
81 95
deleted file mode 100644
... ...
@@ -1,71 +0,0 @@
1
-package ldaputil
2
-
3
-import (
4
-	"testing"
5
-
6
-	"github.com/go-ldap/ldap"
7
-)
8
-
9
-func TestGetAttributeValue(t *testing.T) {
10
-
11
-	testcases := map[string]struct {
12
-		Entry         *ldap.Entry
13
-		Attributes    []string
14
-		ExpectedValue string
15
-	}{
16
-		"empty": {
17
-			Attributes:    []string{},
18
-			Entry:         &ldap.Entry{DN: "", Attributes: []*ldap.EntryAttribute{}},
19
-			ExpectedValue: "",
20
-		},
21
-
22
-		"dn": {
23
-			Attributes:    []string{"dn"},
24
-			Entry:         &ldap.Entry{DN: "foo", Attributes: []*ldap.EntryAttribute{}},
25
-			ExpectedValue: "foo",
26
-		},
27
-		"DN": {
28
-			Attributes:    []string{"DN"},
29
-			Entry:         &ldap.Entry{DN: "foo", Attributes: []*ldap.EntryAttribute{}},
30
-			ExpectedValue: "foo",
31
-		},
32
-
33
-		"missing": {
34
-			Attributes:    []string{"foo", "bar", "baz"},
35
-			Entry:         &ldap.Entry{DN: "", Attributes: []*ldap.EntryAttribute{}},
36
-			ExpectedValue: "",
37
-		},
38
-
39
-		"present": {
40
-			Attributes: []string{"foo"},
41
-			Entry: &ldap.Entry{DN: "", Attributes: []*ldap.EntryAttribute{
42
-				{Name: "foo", Values: []string{"fooValue"}},
43
-			}},
44
-			ExpectedValue: "fooValue",
45
-		},
46
-		"first of multi-value attribute": {
47
-			Attributes: []string{"foo"},
48
-			Entry: &ldap.Entry{DN: "", Attributes: []*ldap.EntryAttribute{
49
-				{Name: "foo", Values: []string{"fooValue", "fooValue2"}},
50
-			}},
51
-			ExpectedValue: "fooValue",
52
-		},
53
-		"first present attribute": {
54
-			Attributes: []string{"foo", "bar", "baz"},
55
-			Entry: &ldap.Entry{DN: "", Attributes: []*ldap.EntryAttribute{
56
-				{Name: "foo", Values: []string{""}},
57
-				{Name: "bar", Values: []string{"barValue"}},
58
-				{Name: "baz", Values: []string{"bazValue"}},
59
-			}},
60
-			ExpectedValue: "barValue",
61
-		},
62
-	}
63
-
64
-	for k, tc := range testcases {
65
-		v := getAttributeValue(tc.Entry, tc.Attributes)
66
-		if v != tc.ExpectedValue {
67
-			t.Errorf("%s: Expected %q, got %q", k, tc.ExpectedValue, v)
68
-		}
69
-	}
70
-
71
-}
... ...
@@ -72,28 +72,28 @@ func (d *LDAPUserAttributeDefiner) AllAttributes() util.StringSet {
72 72
 
73 73
 // Email extracts the email value from an LDAP user entry
74 74
 func (d *LDAPUserAttributeDefiner) Email(user *ldap.Entry) string {
75
-	return getAttributeValue(user, d.attributeMapping.Email)
75
+	return GetAttributeValue(user, d.attributeMapping.Email)
76 76
 }
77 77
 
78 78
 // Name extracts the name value from an LDAP user entry
79 79
 func (d *LDAPUserAttributeDefiner) Name(user *ldap.Entry) string {
80
-	return getAttributeValue(user, d.attributeMapping.Name)
80
+	return GetAttributeValue(user, d.attributeMapping.Name)
81 81
 }
82 82
 
83 83
 // PreferredUsername extracts the preferred username value from an LDAP user entry
84 84
 func (d *LDAPUserAttributeDefiner) PreferredUsername(user *ldap.Entry) string {
85
-	return getAttributeValue(user, d.attributeMapping.PreferredUsername)
85
+	return GetAttributeValue(user, d.attributeMapping.PreferredUsername)
86 86
 }
87 87
 
88 88
 // ID extracts the ID value from an LDAP user entry
89 89
 func (d *LDAPUserAttributeDefiner) ID(user *ldap.Entry) string {
90
-	return getAttributeValue(user, d.attributeMapping.ID)
90
+	return GetAttributeValue(user, d.attributeMapping.ID)
91 91
 }
92 92
 
93
-// getAttributeValue finds the first attribute of those given that the LDAP entry has, and
94
-// returns it. getAttributeValue is able to query the DN as well as Attributes of the LDAP entry.
93
+// GetAttributeValue finds the first attribute of those given that the LDAP entry has, and
94
+// returns it. GetAttributeValue is able to query the DN as well as Attributes of the LDAP entry.
95 95
 // If no value is found, the empty string is returned.
96
-func getAttributeValue(entry *ldap.Entry, attributes []string) string {
96
+func GetAttributeValue(entry *ldap.Entry, attributes []string) string {
97 97
 	for _, k := range attributes {
98 98
 		// Ignore empty attributes
99 99
 		if len(k) == 0 {
100 100
new file mode 100644
... ...
@@ -0,0 +1,71 @@
0
+package ldaputil
1
+
2
+import (
3
+	"testing"
4
+
5
+	"github.com/go-ldap/ldap"
6
+)
7
+
8
+func TestGetAttributeValue(t *testing.T) {
9
+
10
+	testcases := map[string]struct {
11
+		Entry         *ldap.Entry
12
+		Attributes    []string
13
+		ExpectedValue string
14
+	}{
15
+		"empty": {
16
+			Attributes:    []string{},
17
+			Entry:         &ldap.Entry{DN: "", Attributes: []*ldap.EntryAttribute{}},
18
+			ExpectedValue: "",
19
+		},
20
+
21
+		"dn": {
22
+			Attributes:    []string{"dn"},
23
+			Entry:         &ldap.Entry{DN: "foo", Attributes: []*ldap.EntryAttribute{}},
24
+			ExpectedValue: "foo",
25
+		},
26
+		"DN": {
27
+			Attributes:    []string{"DN"},
28
+			Entry:         &ldap.Entry{DN: "foo", Attributes: []*ldap.EntryAttribute{}},
29
+			ExpectedValue: "foo",
30
+		},
31
+
32
+		"missing": {
33
+			Attributes:    []string{"foo", "bar", "baz"},
34
+			Entry:         &ldap.Entry{DN: "", Attributes: []*ldap.EntryAttribute{}},
35
+			ExpectedValue: "",
36
+		},
37
+
38
+		"present": {
39
+			Attributes: []string{"foo"},
40
+			Entry: &ldap.Entry{DN: "", Attributes: []*ldap.EntryAttribute{
41
+				{Name: "foo", Values: []string{"fooValue"}},
42
+			}},
43
+			ExpectedValue: "fooValue",
44
+		},
45
+		"first of multi-value attribute": {
46
+			Attributes: []string{"foo"},
47
+			Entry: &ldap.Entry{DN: "", Attributes: []*ldap.EntryAttribute{
48
+				{Name: "foo", Values: []string{"fooValue", "fooValue2"}},
49
+			}},
50
+			ExpectedValue: "fooValue",
51
+		},
52
+		"first present attribute": {
53
+			Attributes: []string{"foo", "bar", "baz"},
54
+			Entry: &ldap.Entry{DN: "", Attributes: []*ldap.EntryAttribute{
55
+				{Name: "foo", Values: []string{""}},
56
+				{Name: "bar", Values: []string{"barValue"}},
57
+				{Name: "baz", Values: []string{"bazValue"}},
58
+			}},
59
+			ExpectedValue: "barValue",
60
+		},
61
+	}
62
+
63
+	for k, tc := range testcases {
64
+		v := GetAttributeValue(tc.Entry, tc.Attributes)
65
+		if v != tc.ExpectedValue {
66
+			t.Errorf("%s: Expected %q, got %q", k, tc.ExpectedValue, v)
67
+		}
68
+	}
69
+
70
+}
0 71
new file mode 100644
... ...
@@ -0,0 +1,211 @@
0
+package ldaputil
1
+
2
+import (
3
+	"fmt"
4
+	"strings"
5
+
6
+	"github.com/go-ldap/ldap"
7
+	"github.com/golang/glog"
8
+
9
+	"k8s.io/kubernetes/pkg/util"
10
+
11
+	"github.com/openshift/origin/pkg/cmd/server/api"
12
+)
13
+
14
+// LDAPQuery encodes an LDAP query
15
+type LDAPQuery struct {
16
+	// The DN of the branch of the directory where all searches should start from
17
+	BaseDN string
18
+
19
+	// The (optional) scope of the search. Defaults to the entire subtree if not set
20
+	Scope Scope
21
+
22
+	// The (optional) behavior of the search with regards to alisases. Defaults to always
23
+	// dereferencing if not set
24
+	DerefAliases DerefAliases
25
+
26
+	// TimeLimit holds the limit of time in seconds that any request to the server can remain outstanding
27
+	// before the wait for a response is given up. If this is 0, no client-side limit is imposed
28
+	TimeLimit int
29
+
30
+	// Filter is a valid LDAP search filter that retrieves all relevant entries from the LDAP server with the base DN
31
+	Filter string
32
+}
33
+
34
+// NewSearchRequest creates a new search request for the LDAP query and optionally includes more attributes
35
+func (q *LDAPQuery) NewSearchRequest(additionalAttributes []string) *ldap.SearchRequest {
36
+	return ldap.NewSearchRequest(
37
+		q.BaseDN,
38
+		int(q.Scope),
39
+		int(q.DerefAliases),
40
+		0, // allowed return size - indicates no limit
41
+		q.TimeLimit,
42
+		false, // not types only
43
+		q.Filter,
44
+		additionalAttributes,
45
+		nil, // no controls
46
+	)
47
+}
48
+
49
+// LDAPQueryOnAttribute encodes an LDAP query that conjoins two filters to extract a specific LDAP entry
50
+// This query is not self-sufficient and needs the value of the QueryAttribute to construct the final filter
51
+type LDAPQueryOnAttribute struct {
52
+	// Query retrieves entries from an LDAP server
53
+	LDAPQuery
54
+
55
+	// QueryAttribute is the attribute for a specific filter that, when conjoined with the common filter,
56
+	// retrieves the specific LDAP entry from the LDAP server. (e.g. "cn", when formatted with "aGroupName"
57
+	// and conjoined with "objectClass=groupOfNames", becomes (&(objectClass=groupOfNames)(cn=aGroupName))")
58
+	QueryAttribute string
59
+}
60
+
61
+// IdentifiyingLDAPQueryOptions holds a query and the attribute that identifies the entries that the query returns
62
+type IdentifiyingLDAPQueryOptions struct {
63
+	// Query retrieves entries from an LDAP server
64
+	LDAPQueryOnAttribute
65
+
66
+	// NameAttributes defines the attributes for the LDAP entries returned by the Query that will be interpreted
67
+	// as their names
68
+	NameAttributes []string
69
+}
70
+
71
+// NewIdentifiyingLDAPQueryOptions converts a user-provided LDAPQuery into a version we can use by parsing
72
+// the input and combining it with a set of name attributes
73
+func NewIdentifiyingLDAPQueryOptions(config api.LDAPQuery,
74
+	nameAttributes []string) (IdentifiyingLDAPQueryOptions, error) {
75
+
76
+	scope, err := DetermineLDAPScope(config.Scope)
77
+	if err != nil {
78
+		return IdentifiyingLDAPQueryOptions{}, err
79
+	}
80
+
81
+	derefAliases, err := DetermineDerefAliasesBehavior(config.DerefAliases)
82
+	if err != nil {
83
+		return IdentifiyingLDAPQueryOptions{}, err
84
+	}
85
+
86
+	return IdentifiyingLDAPQueryOptions{
87
+		LDAPQueryOnAttribute: LDAPQueryOnAttribute{
88
+			LDAPQuery: LDAPQuery{
89
+				BaseDN:       config.BaseDN,
90
+				Scope:        scope,
91
+				DerefAliases: derefAliases,
92
+				TimeLimit:    config.TimeLimit,
93
+				Filter:       config.Filter,
94
+			},
95
+			QueryAttribute: config.QueryAttribute,
96
+		},
97
+		NameAttributes: nameAttributes,
98
+	}, nil
99
+}
100
+
101
+// NewSearchRequest creates a new search request from the identifying query by internalizing the value of
102
+// the attribute to be filtered as well as any additional attributest that need to be recovereds
103
+func (o *IdentifiyingLDAPQueryOptions) NewSearchRequest(attributeValue string,
104
+	additionalAttributes []string) (*ldap.SearchRequest, error) {
105
+
106
+	allAttributes := util.NewStringSet(o.NameAttributes...)
107
+	allAttributes.Insert(additionalAttributes...)
108
+
109
+	if o.QueryAttribute == "DN" || o.QueryAttribute == "dn" {
110
+		if !strings.Contains(attributeValue, o.BaseDN) {
111
+			return nil, fmt.Errorf("search for entry with %s=%s would search outside of the base dn specified (dn=%s)",
112
+				o.QueryAttribute, attributeValue, o.BaseDN)
113
+		}
114
+		if _, err := ldap.ParseDN(attributeValue); err != nil {
115
+			return nil, fmt.Errorf("could not search by dn, invalid dn value: %v", err)
116
+		}
117
+		return o.buildDNQuery(attributeValue, allAttributes.List()), nil
118
+	} else {
119
+		return o.buildAttributeQuery(attributeValue, allAttributes.List()), nil
120
+	}
121
+}
122
+
123
+// buildDNQuery builds the query that finds an LDAP entry with the given DN
124
+// this is done by setting the DN to be the base DN for the search and setting the search scope
125
+// to only consider the base object found
126
+func (o *IdentifiyingLDAPQueryOptions) buildDNQuery(dn string,
127
+	attributes []string) *ldap.SearchRequest {
128
+	return ldap.NewSearchRequest(
129
+		dn,
130
+		ldap.ScopeBaseObject, // over-ride original
131
+		int(o.DerefAliases),
132
+		0, // allowed return size - indicates no limit
133
+		o.TimeLimit,
134
+		false,           // not types only
135
+		"objectClass=*", // filter that returns all values
136
+		attributes,
137
+		nil, // no controls
138
+	)
139
+}
140
+
141
+// buildAttributeQuery builds the query containing a filter that conjoins the common filter given
142
+// in the configuration with the specific attribute filter for which the attribute value is given
143
+func (o *IdentifiyingLDAPQueryOptions) buildAttributeQuery(attributeValue string,
144
+	attributes []string) *ldap.SearchRequest {
145
+	specificFilter := fmt.Sprintf("%s=%s",
146
+		ldap.EscapeFilter(o.QueryAttribute),
147
+		ldap.EscapeFilter(attributeValue))
148
+
149
+	filter := fmt.Sprintf("(&(%s)(%s))", o.Filter, specificFilter)
150
+
151
+	return ldap.NewSearchRequest(
152
+		o.BaseDN,
153
+		int(o.Scope),
154
+		int(o.DerefAliases),
155
+		0, // allowed return size - indicates no limit
156
+		o.TimeLimit,
157
+		false, // not types only
158
+		filter,
159
+		attributes,
160
+		nil, // no controls
161
+	)
162
+}
163
+
164
+// QueryForUniqueEntry queries for an LDAP entry on an LDAP server determined from a ClientConfig
165
+// by creating a search request from the requisite input. The query is expected to return one unqiue
166
+// result. If this is not the case, errors are raised
167
+func QueryForUniqueEntry(clientConfig LDAPClientConfig,
168
+	query *ldap.SearchRequest) (entry *ldap.Entry, err error) {
169
+
170
+	result, err := QueryForEntries(clientConfig, query)
171
+	if err != nil {
172
+		return nil, err
173
+	}
174
+	if len(result) > 1 {
175
+		return nil, fmt.Errorf("multiple entries found matching %s", query.Filter)
176
+	}
177
+	entry = result[0]
178
+	glog.V(4).Infof("found dn=%q for %s", entry.DN, query.Filter)
179
+	return entry, nil
180
+}
181
+
182
+// QueryForEntries queries for LDAP entries on an LDAP server determined from a ClientConfig by
183
+// creating a search request from the requisite input.
184
+func QueryForEntries(clientConfig LDAPClientConfig,
185
+	query *ldap.SearchRequest) (result []*ldap.Entry, err error) {
186
+
187
+	connection, err := clientConfig.Connect()
188
+	if err != nil {
189
+		return nil, err
190
+	}
191
+	defer connection.Close()
192
+
193
+	glog.V(4).Infof("searching LDAP server for %s", query.Filter)
194
+	searchResult, err := connection.Search(query)
195
+	if err != nil {
196
+		return nil, err
197
+	}
198
+
199
+	entries := searchResult.Entries
200
+	// No entries returned from the LDAP search request means that the LDAP search was not configured
201
+	// correctly. The search must return with at least one LDAP entry
202
+	if len(entries) == 0 {
203
+		return nil, fmt.Errorf("no LDAP user entry found for filter: %s", query.Filter)
204
+	}
205
+
206
+	for _, entry := range entries {
207
+		glog.V(4).Infof("found dn=%q for %s", entry.DN, query.Filter)
208
+	}
209
+	return entries, nil
210
+}
0 211
new file mode 100644
... ...
@@ -0,0 +1,11 @@
0
+package ldaputil
1
+
2
+// These constants contain values for annotations and labels affixed to Groups by the LDAP sync job
3
+const (
4
+	// LDAPURLAnnotation is the Annotation value that stores the host:port of the LDAP server
5
+	LDAPURLAnnotation string = "openshift.io/ldap.url"
6
+	// LDAPUIDAnnotation is the Annotation value that stores the corresponding LDAP group UID for the Group
7
+	LDAPUIDAnnotation string = "openshift.io/ldap.uid"
8
+	// LDAPSyncTime is the Annotation value that stores the last time this Group was synced with LDAP
9
+	LDAPSyncTimeAnnotation string = "openshift.io/ldap.sync-time"
10
+)
... ...
@@ -242,7 +242,7 @@ func DetermineDerefAliasesBehavior(derefAliasesString string) (DerefAliases, err
242 242
 	}
243 243
 	derefAliases, exists := mapping[derefAliasesString]
244 244
 	if !exists {
245
-		return -1, fmt.Errorf("not a valid LDAP search scope: %s", derefAliasesString)
245
+		return -1, fmt.Errorf("not a valid LDAP alias dereferncing behavior: %s", derefAliasesString)
246 246
 	}
247 247
 	return derefAliases, nil
248 248
 }
249 249
new file mode 100644
... ...
@@ -0,0 +1,124 @@
0
+package syncgroups
1
+
2
+import (
3
+	"fmt"
4
+
5
+	"k8s.io/kubernetes/pkg/fields"
6
+	"k8s.io/kubernetes/pkg/labels"
7
+
8
+	"github.com/openshift/origin/pkg/auth/ldaputil"
9
+	osclient "github.com/openshift/origin/pkg/client"
10
+	"github.com/openshift/origin/pkg/cmd/experimental/syncgroups/interfaces"
11
+	ouserapi "github.com/openshift/origin/pkg/user/api"
12
+)
13
+
14
+// NewAllOpenShiftGroupLister returns a new AllLocalGroupLister
15
+func NewAllOpenShiftGroupLister(ldapURL string, groupClient osclient.GroupInterface) interfaces.LDAPGroupLister {
16
+	return &AllLocalGroupLister{
17
+		client:  groupClient,
18
+		ldapURL: ldapURL,
19
+	}
20
+}
21
+
22
+// AllLocalGroupLister lists unique identifiers for LDAP lookup of all local OpenShift Groups that
23
+// have been marked with an LDAP URL annotation as a result of a previous sync.
24
+type AllLocalGroupLister struct {
25
+	client osclient.GroupInterface
26
+	// ldapURL is the host:port of the LDAP server, used to identify if an OpenShift Group has
27
+	// been synced with a specific server in order to isolate sync jobs between different servers
28
+	ldapURL string
29
+}
30
+
31
+func (l *AllLocalGroupLister) ListGroups() (ldapGroupUIDs []string, err error) {
32
+	allGroups, err := l.client.List(labels.Everything(), fields.Everything())
33
+	if err != nil {
34
+		return nil, err
35
+	}
36
+
37
+	var potentialGroups []ouserapi.Group
38
+	for _, group := range allGroups.Items {
39
+		val, exists := group.Annotations[ldaputil.LDAPURLAnnotation]
40
+		if exists && (val == l.ldapURL) {
41
+			potentialGroups = append(potentialGroups, group)
42
+		}
43
+	}
44
+
45
+	for _, group := range potentialGroups {
46
+		if err := validateGroupAnnotations(group); err != nil {
47
+			return nil, err
48
+		}
49
+		ldapGroupUIDs = append(ldapGroupUIDs, group.Annotations[ldaputil.LDAPUIDAnnotation])
50
+	}
51
+	return ldapGroupUIDs, nil
52
+}
53
+
54
+// validateGroupAnnotations determines if the appropriate and annotations exist on a group
55
+func validateGroupAnnotations(group ouserapi.Group) error {
56
+	_, exists := group.Annotations[ldaputil.LDAPUIDAnnotation]
57
+	if !exists {
58
+		return fmt.Errorf("an OpenShift Group marked as having been synced did not have a %s annotation: %v",
59
+			ldaputil.LDAPUIDAnnotation, group)
60
+	}
61
+	return nil
62
+}
63
+
64
+// NewOpenShiftWhitelistGroupLister returns a new LocalGroupLister that divulges the LDAP group unique identifier for
65
+// each entry in the given whitelist of OpenShift Group names
66
+func NewOpenShiftWhitelistGroupLister(whitelist []string, client osclient.GroupInterface) interfaces.LDAPGroupLister {
67
+	return &LocalGroupLister{
68
+		whitelist: whitelist,
69
+		client:    client,
70
+	}
71
+}
72
+
73
+// LocalGroupLister lists unique identifiers for LDAP lookup of all local OpenShift groups that have
74
+// been given to it upon creation.
75
+type LocalGroupLister struct {
76
+	whitelist []string
77
+	client    osclient.GroupInterface
78
+}
79
+
80
+func (l *LocalGroupLister) ListGroups() (ldapGroupUIDs []string, err error) {
81
+	groups, err := getOpenShiftGroups(l.whitelist, l.client)
82
+	if err != nil {
83
+		return nil, err
84
+	}
85
+
86
+	for _, group := range groups {
87
+		if err := validateGroupAnnotations(group); err != nil {
88
+			return nil, err
89
+		}
90
+		ldapGroupUIDs = append(ldapGroupUIDs, group.Annotations[ldaputil.LDAPUIDAnnotation])
91
+	}
92
+	return ldapGroupUIDs, err
93
+}
94
+
95
+// getOpenShiftGroups uses a client to retrieve all groups from the names given
96
+func getOpenShiftGroups(names []string, client osclient.GroupInterface) ([]ouserapi.Group, error) {
97
+	var groups []ouserapi.Group
98
+	for _, name := range names {
99
+		group, err := client.Get(name)
100
+		if err != nil {
101
+			return nil, err
102
+		}
103
+		groups = append(groups, *group)
104
+	}
105
+	return groups, nil
106
+}
107
+
108
+// NewLDAPWhitelistGroupLister returns a new WhitelistLDAPGroupLister that divulges the given whitelist
109
+// of LDAP group unique identifiers
110
+func NewLDAPWhitelistGroupLister(whitelist []string) interfaces.LDAPGroupLister {
111
+	return &WhitelistLDAPGroupLister{
112
+		GroupUIDs: whitelist,
113
+	}
114
+}
115
+
116
+// LDAPGroupLister lists LDAP groups unique group identifiers given to it upon creation.
117
+type WhitelistLDAPGroupLister struct {
118
+	GroupUIDs []string
119
+}
120
+
121
+func (l *WhitelistLDAPGroupLister) ListGroups() (ldapGroupUIDs []string, err error) {
122
+	return l.GroupUIDs, nil
123
+}
0 124
new file mode 100644
... ...
@@ -0,0 +1,59 @@
0
+package syncgroups
1
+
2
+import (
3
+	"fmt"
4
+
5
+	"github.com/openshift/origin/pkg/auth/ldaputil"
6
+	"github.com/openshift/origin/pkg/cmd/experimental/syncgroups/interfaces"
7
+)
8
+
9
+// NewUserDefinedGroupNameMapper returns a new UserDefinedLDAPGroupNameMapper which maps a ldapGroupUID
10
+// representing an LDAP group to the OpenShift Group name for the resource
11
+func NewUserDefinedGroupNameMapper(mapping map[string]string) interfaces.LDAPGroupNameMapper {
12
+	return &UserDefinedLDAPGroupNameMapper{
13
+		nameMapping: mapping,
14
+	}
15
+}
16
+
17
+// UserDefinedLDAPGroupNameMapper maps a ldapGroupUID representing an LDAP group to the OpenShift Group
18
+// name for the resource by using a pre-defined mapping of ldapGroupUID to name (e.g. from a file)
19
+type UserDefinedLDAPGroupNameMapper struct {
20
+	nameMapping map[string]string
21
+}
22
+
23
+func (m *UserDefinedLDAPGroupNameMapper) GroupNameFor(ldapGroupUID string) (string, error) {
24
+	openShiftGroupName, exists := m.nameMapping[ldapGroupUID]
25
+	if !exists {
26
+		return "", fmt.Errorf("no OpenShift Group name defined for LDAP group UID: %s", ldapGroupUID)
27
+	}
28
+	return openShiftGroupName, nil
29
+}
30
+
31
+// NewEntryAttributeGroupNameMapper returns a new EntryAttributeLDAPGroupNameMapper
32
+func NewEntryAttributeGroupNameMapper(nameAttribute []string,
33
+	groupGetter interfaces.LDAPGroupGetter) interfaces.LDAPGroupNameMapper {
34
+	return &EntryAttributeLDAPGroupNameMapper{
35
+		nameAttribute: nameAttribute,
36
+		groupGetter:   groupGetter,
37
+	}
38
+}
39
+
40
+// EntryAttributeLDAPGroupNameMapper references the name attribute mapping to determine which attribute
41
+// of a first-class LDAP group entry should be used as the OpenShift Group name for the resource
42
+type EntryAttributeLDAPGroupNameMapper struct {
43
+	nameAttribute []string
44
+	groupGetter   interfaces.LDAPGroupGetter
45
+}
46
+
47
+func (m *EntryAttributeLDAPGroupNameMapper) GroupNameFor(ldapGroupUID string) (string, error) {
48
+	group, err := m.groupGetter.GroupEntryFor(ldapGroupUID)
49
+	if err != nil {
50
+		return "", err
51
+	}
52
+	openShiftGroupName := ldaputil.GetAttributeValue(group, m.nameAttribute)
53
+	if len(openShiftGroupName) == 0 {
54
+		return "", fmt.Errorf("the group entry (%v) does not map to an OpenShift Group name with the given name attribute (%v)",
55
+			group, m.nameAttribute)
56
+	}
57
+	return openShiftGroupName, nil
58
+}
0 59
new file mode 100644
... ...
@@ -0,0 +1,141 @@
0
+package syncgroups
1
+
2
+import (
3
+	"fmt"
4
+	"time"
5
+
6
+	"github.com/go-ldap/ldap"
7
+
8
+	"github.com/openshift/origin/pkg/auth/ldaputil"
9
+	"github.com/openshift/origin/pkg/client"
10
+	"github.com/openshift/origin/pkg/cmd/experimental/syncgroups/interfaces"
11
+	ouserapi "github.com/openshift/origin/pkg/user/api"
12
+)
13
+
14
+// GroupSyncer runs a Sync job on Groups
15
+type GroupSyncer interface {
16
+	Sync() (errors []error)
17
+}
18
+
19
+// LDAPGroupSyncer sync Groups with records on an external LDAP server
20
+type LDAPGroupSyncer struct {
21
+	// Lists all groups to be synced
22
+	GroupLister interfaces.LDAPGroupLister
23
+	// Fetches a group and extracts object metainformation and membership list from a group
24
+	GroupMemberExtractor interfaces.LDAPMemberExtractor
25
+	// Maps an LDAP user entry to an OpenShift User's Name
26
+	UserNameMapper interfaces.LDAPUserNameMapper
27
+	// Maps an LDAP group enrty to an OpenShift Group's Name
28
+	GroupNameMapper interfaces.LDAPGroupNameMapper
29
+	// Allows the Syncer to search for OpenShift Groups
30
+	GroupClient client.GroupInterface
31
+	// Host stores the address:port of the LDAP server
32
+	Host string
33
+}
34
+
35
+// Sync allows the LDAPGroupSyncer to be a GroupSyncer
36
+func (s *LDAPGroupSyncer) Sync() []error {
37
+	var errors []error
38
+	// determine what to sync
39
+	ldapGroupUIDs, err := s.GroupLister.ListGroups()
40
+	if err != nil {
41
+		errors = append(errors, err)
42
+		return errors
43
+	}
44
+
45
+	for _, ldapGroupUID := range ldapGroupUIDs {
46
+		// get membership data
47
+		memberEntries, err := s.GroupMemberExtractor.ExtractMembers(ldapGroupUID)
48
+		if err != nil {
49
+			errors = append(errors, err)
50
+			continue
51
+		}
52
+
53
+		// determine OpenShift Users' usernames for LDAP group members
54
+		usernames, err := s.determineUsernames(memberEntries)
55
+		if err != nil {
56
+			errors = append(errors, err)
57
+			continue
58
+		}
59
+
60
+		// update the OpenShift Group corresponding to this record
61
+		err = s.updateGroup(ldapGroupUID, usernames)
62
+		if err != nil {
63
+			errors = append(errors, err)
64
+		}
65
+
66
+	}
67
+	return errors
68
+}
69
+
70
+// determineUsers determines the OpenShift Users that correspond to a list of LDAP member entries
71
+func (s *LDAPGroupSyncer) determineUsernames(members []*ldap.Entry) ([]string, error) {
72
+	var usernames []string
73
+	for _, member := range members {
74
+		username, err := s.UserNameMapper.UserNameFor(member)
75
+		if err != nil {
76
+			return nil, err
77
+		}
78
+		usernames = append(usernames, username)
79
+	}
80
+	return usernames, nil
81
+}
82
+
83
+// updateGroup finds or creates the OpenShift Group that needs to be updated, updates its' data, then
84
+// uses the GroupClient to update the Group record
85
+func (s *LDAPGroupSyncer) updateGroup(ldapGroupUID string, usernames []string) error {
86
+	// find OpenShift Group to update
87
+	group, err := s.findGroup(ldapGroupUID)
88
+	if err != nil {
89
+		return err
90
+	}
91
+
92
+	// overwrite Group Users data
93
+	group.Users = usernames
94
+
95
+	// add LDAP-sync-specific annotations
96
+	group.Annotations[ldaputil.LDAPUIDAnnotation] = ldapGroupUID
97
+	group.Annotations[ldaputil.LDAPSyncTimeAnnotation] = ISO8601(time.Now())
98
+	group.Annotations[ldaputil.LDAPURLAnnotation] = s.Host
99
+
100
+	_, err = s.GroupClient.Update(group)
101
+	return err
102
+}
103
+
104
+// findGroup finds the OpenShift Group for the LDAP group UID and ensures that the OpenShift Group found
105
+// was created as a result of a previous LDAP sync from the same LDAP group.
106
+func (s *LDAPGroupSyncer) findGroup(ldapGroupUID string) (*ouserapi.Group, error) {
107
+	groupName, err := s.GroupNameMapper.GroupNameFor(ldapGroupUID)
108
+	if err != nil {
109
+		return nil, err
110
+	}
111
+
112
+	group, err := s.GroupClient.Get(groupName)
113
+	if err != nil {
114
+		return nil, fmt.Errorf("could not get group for name: %s", groupName)
115
+	}
116
+
117
+	url, exists := group.Annotations[ldaputil.LDAPURLAnnotation]
118
+	if !exists || url != s.Host {
119
+		return nil, fmt.Errorf("group %s's %s annotation did not match sync host: wanted %s, got %s",
120
+			group.Name, ldaputil.LDAPURLAnnotation, s.Host, url)
121
+	}
122
+	uid, exists := group.Annotations[ldaputil.LDAPUIDAnnotation]
123
+	if !exists || uid != ldapGroupUID {
124
+		return nil, fmt.Errorf("group %s's %s annotation did not match sync host: wanted %s, got %s",
125
+			group.Name, ldaputil.LDAPUIDAnnotation, ldapGroupUID, uid)
126
+	}
127
+	return group, nil
128
+}
129
+
130
+// ISO8601 returns an ISO 6801 formatted string from a time.
131
+func ISO8601(t time.Time) string {
132
+	var tz string
133
+	if zone, offset := t.Zone(); zone == "UTC" {
134
+		tz = "Z"
135
+	} else {
136
+		tz = fmt.Sprintf("%03d00", offset/3600)
137
+	}
138
+	return fmt.Sprintf("%04d-%02d-%02dT%02d:%02d:%02d%s",
139
+		t.Year(), t.Month(), t.Day(), t.Hour(), t.Minute(), t.Second(), tz)
140
+}
0 141
new file mode 100644
... ...
@@ -0,0 +1,32 @@
0
+package interfaces
1
+
2
+import "github.com/go-ldap/ldap"
3
+
4
+// LDAPGroupLister lists the LDAP groups that need to be synced by a job. The LDAPGroupLister needs to
5
+// be paired with an LDAPMemberExtractor that understands the format of the unique identifiers returned
6
+// to represent the LDAP groups to be synced.
7
+type LDAPGroupLister interface {
8
+	ListGroups() (ldapGroupUIDs []string, err error)
9
+}
10
+
11
+// LDAPMemberExtractor retrieves member data about an LDAP group from the LDAP server.
12
+type LDAPMemberExtractor interface {
13
+	// ExtractMembers returns the list of LDAP first-class user entries that are members of the LDAP group
14
+	// specified by the ldapGroupUID
15
+	ExtractMembers(ldapGroupUID string) (members []*ldap.Entry, err error)
16
+}
17
+
18
+// LDAPGroupNameMapper maps a ldapGroupUID representing an LDAP group to the OpenShift Group name for the resource
19
+type LDAPGroupNameMapper interface {
20
+	GroupNameFor(ldapGroupUID string) (openShiftGroupName string, err error)
21
+}
22
+
23
+// LDAPUserNameMapper maps an LDAP entry representing an LDAP user to the OpenShift User name for the resource
24
+type LDAPUserNameMapper interface {
25
+	UserNameFor(ldapUser *ldap.Entry) (openShiftUserName string, err error)
26
+}
27
+
28
+// LDAPGroupGetter maps a ldapGroupUID to a first-class LDAP group entry
29
+type LDAPGroupGetter interface {
30
+	GroupEntryFor(ldapGroupUID string) (group *ldap.Entry, err error)
31
+}
0 32
new file mode 100644
... ...
@@ -0,0 +1,154 @@
0
+package rfc2307
1
+
2
+import (
3
+	"github.com/go-ldap/ldap"
4
+
5
+	"github.com/openshift/origin/pkg/auth/ldaputil"
6
+)
7
+
8
+// NewLDAPInterface builds a new LDAPInterface using a schema-appropriate config
9
+func NewLDAPInterface(clientConfig ldaputil.LDAPClientConfig,
10
+	groupQuery ldaputil.IdentifiyingLDAPQueryOptions,
11
+	userQuery ldaputil.IdentifiyingLDAPQueryOptions,
12
+	groupMembershipAttributes []string) LDAPInterface {
13
+	return LDAPInterface{
14
+		clientConfig:              clientConfig,
15
+		groupQuery:                groupQuery,
16
+		userQuery:                 userQuery,
17
+		groupMembershipAttributes: groupMembershipAttributes,
18
+		cachedUsers:               make(map[string]*ldap.Entry),
19
+		cachedGroups:              make(map[string]*ldap.Entry),
20
+	}
21
+}
22
+
23
+// LDAPInterface extracts the member list of an LDAP group entry from an LDAP server
24
+// with first-class LDAP entries for groups. The LDAPInterface is *NOT* thread-safe.
25
+// The LDAPInterface satisfies:
26
+// - LDAPMemberExtractor
27
+// - LDAPGroupGetter
28
+// - LDAPGroupLister
29
+type LDAPInterface struct {
30
+	// clientConfig holds LDAP connection information
31
+	clientConfig ldaputil.LDAPClientConfig
32
+	// groupQuery holds the information necessary to make an LDAP query for a specific
33
+	// first-class group entry on the LDAP server
34
+	groupQuery ldaputil.IdentifiyingLDAPQueryOptions
35
+	// userQuery holds the information necessary to make an LDAP query for a specific
36
+	// first-class user entry on the LDAP server
37
+	userQuery ldaputil.IdentifiyingLDAPQueryOptions
38
+	// groupMembershipAttributes defines which attributes on an LDAP user entry will be interpreted
39
+	// as the groups it is a member of
40
+	groupMembershipAttributes []string
41
+
42
+	// cachedGroups holds the result of group queries for later reference, indexed on group UID
43
+	// e.g. this will map an LDAP group UID to the LDAP entry returned from the query made using it
44
+	cachedGroups map[string]*ldap.Entry
45
+	// cachedUsers holds the result of user queries for later reference, indexed on user UID
46
+	// e.g. this will map an LDAP user UID to the LDAP entry returned from the query made using it
47
+	cachedUsers map[string]*ldap.Entry
48
+}
49
+
50
+// ExtractMembers returns the LDAP member entries for a group specified with a ldapGroupUID
51
+func (e *LDAPInterface) ExtractMembers(ldapGroupUID string) (members []*ldap.Entry, err error) {
52
+	// get group entry from LDAP
53
+	group, err := e.GroupEntryFor(ldapGroupUID)
54
+	if err != nil {
55
+		return nil, err
56
+	}
57
+
58
+	// extract member UIDs from group entry
59
+	var ldapMemberUIDs []string
60
+	for _, attribute := range e.userQuery.NameAttributes {
61
+		ldapMemberUIDs = append(ldapMemberUIDs, group.GetAttributeValues(attribute)...)
62
+	}
63
+
64
+	// find members on LDAP server or in cache
65
+	for _, ldapMemberUID := range ldapMemberUIDs {
66
+		memberEntry, err := e.userEntryFor(ldapMemberUID)
67
+		if err != nil {
68
+			return nil, err
69
+		}
70
+		members = append(members, memberEntry)
71
+	}
72
+	return members, nil
73
+}
74
+
75
+// GroupFor returns an LDAP group entry for the given group UID by searching the internal cache
76
+// of the LDAPInterface first, then sending an LDAP query if the cache did not contain the entry.
77
+// This also satisfies the LDAPGroupGetter interface
78
+func (e *LDAPInterface) GroupEntryFor(ldapGroupUID string) (group *ldap.Entry, err error) {
79
+	group, exists := e.cachedGroups[ldapGroupUID]
80
+	if !exists {
81
+		group, err = e.queryForGroup(ldapGroupUID)
82
+		if err != nil {
83
+			return nil, err
84
+		}
85
+		// cache for annotation extraction
86
+		e.cachedGroups[ldapGroupUID] = group
87
+	}
88
+	return group, nil
89
+}
90
+
91
+// queryForGroup queries for a specific group identified by a ldapGroupUID with the query config stored
92
+// in a LDAPInterface
93
+func (e *LDAPInterface) queryForGroup(ldapGroupUID string) (group *ldap.Entry, err error) {
94
+	// create the search request
95
+	searchRequest, err := e.groupQuery.NewSearchRequest(ldapGroupUID, e.groupMembershipAttributes)
96
+	if err != nil {
97
+		return nil, err
98
+	}
99
+
100
+	return ldaputil.QueryForUniqueEntry(e.clientConfig, searchRequest)
101
+}
102
+
103
+// userEntryFor returns an LDAP group entry for the given group UID by searching the internal cache
104
+// of the LDAPInterface first, then sending an LDAP query if the cache did not contain the entry
105
+func (e *LDAPInterface) userEntryFor(ldapUserUID string) (user *ldap.Entry, err error) {
106
+	user, exists := e.cachedUsers[ldapUserUID]
107
+	if !exists {
108
+		user, err = e.queryForUser(ldapUserUID)
109
+		if err != nil {
110
+			return nil, err
111
+		}
112
+		// cache for annotation extraction
113
+		e.cachedUsers[ldapUserUID] = user
114
+	}
115
+	return user, nil
116
+}
117
+
118
+// queryForUser queries for an LDAP user entry identified with an LDAP user UID on an LDAP server
119
+// determined from a clientConfig by creating a search request from an LDAP query template and
120
+// determining which attributes to search for with a LDAPuserAttributeDefiner
121
+func (e *LDAPInterface) queryForUser(ldapUserUID string) (user *ldap.Entry, err error) {
122
+	// create the search request
123
+	searchRequest, err := e.userQuery.NewSearchRequest(ldapUserUID, []string{})
124
+	if err != nil {
125
+		return nil, err
126
+	}
127
+
128
+	return ldaputil.QueryForUniqueEntry(e.clientConfig, searchRequest)
129
+}
130
+
131
+// ListGroups queries for all groups as configured with the common group filter and returns their
132
+// LDAP group UIDs. This also satisfies the LDAPGroupLister interface
133
+func (e *LDAPInterface) ListGroups() (ldapGroupUIDs []string, err error) {
134
+	groups, err := e.queryForGroups()
135
+	if err != nil {
136
+		return nil, err
137
+	}
138
+	for _, group := range groups {
139
+		// cache groups returned from the server for later
140
+		ldapGroupUID := ldaputil.GetAttributeValue(group, e.groupQuery.NameAttributes)
141
+		e.cachedGroups[ldapGroupUID] = group
142
+		ldapGroupUIDs = append(ldapGroupUIDs, ldapGroupUID)
143
+	}
144
+	return ldapGroupUIDs, nil
145
+}
146
+
147
+// queryForGroups queries for all groups identified by a common filter in the query config stored
148
+// in a GroupListerDataExtractor
149
+func (e *LDAPInterface) queryForGroups() (groups []*ldap.Entry, err error) {
150
+	// create the search request
151
+	searchRequest := e.groupQuery.LDAPQuery.NewSearchRequest(e.groupMembershipAttributes)
152
+	return ldaputil.QueryForEntries(e.clientConfig, searchRequest)
153
+}
0 154
new file mode 100644
... ...
@@ -0,0 +1,7 @@
0
+package syncgroups
1
+
2
+import "github.com/openshift/origin/pkg/cmd/experimental/syncgroups/interfaces"
3
+
4
+var _ interfaces.LDAPMemberExtractor = &LDAPInterface{}
5
+var _ interfaces.LDAPGroupGetter = &LDAPInterface{}
6
+var _ interfaces.LDAPGroupLister = &LDAPInterface{}
0 7
new file mode 100644
... ...
@@ -0,0 +1,32 @@
0
+package syncgroups
1
+
2
+import (
3
+	"fmt"
4
+
5
+	"github.com/go-ldap/ldap"
6
+
7
+	"github.com/openshift/origin/pkg/auth/ldaputil"
8
+	"github.com/openshift/origin/pkg/cmd/experimental/syncgroups/interfaces"
9
+)
10
+
11
+// NewUserNameMapper returns a new DefaultLDAPGroupUserNameMapper
12
+func NewUserNameMapper(nameAttributes []string) interfaces.LDAPUserNameMapper {
13
+	return &DefaultLDAPUserNameMapper{
14
+		nameAttributes: nameAttributes,
15
+	}
16
+}
17
+
18
+// DefaultLDAPUserNameMapper extracts the OpenShift User name of an LDAP entry representing
19
+// a user in a deterministic manner
20
+type DefaultLDAPUserNameMapper struct {
21
+	nameAttributes []string
22
+}
23
+
24
+func (m *DefaultLDAPUserNameMapper) UserNameFor(ldapUser *ldap.Entry) (openShiftUserName string, err error) {
25
+	openShiftUserName = ldaputil.GetAttributeValue(ldapUser, m.nameAttributes)
26
+	if len(openShiftUserName) == 0 {
27
+		return "", fmt.Errorf("the user entry (%v) does not map to a OpenShift User name with the given mapping",
28
+			ldapUser)
29
+	}
30
+	return openShiftUserName, nil
31
+}
... ...
@@ -23,6 +23,8 @@ func init() {
23 23
 		&GoogleIdentityProvider{},
24 24
 		&OpenIDIdentityProvider{},
25 25
 		&GrantConfig{},
26
+
27
+		&LDAPSyncConfig{},
26 28
 	)
27 29
 }
28 30
 
... ...
@@ -41,3 +43,5 @@ func (*GrantConfig) IsAnAPIObject()                       {}
41 41
 func (*MasterConfig) IsAnAPIObject()   {}
42 42
 func (*NodeConfig) IsAnAPIObject()     {}
43 43
 func (*SessionSecrets) IsAnAPIObject() {}
44
+
45
+func (*LDAPSyncConfig) IsAnAPIObject() {}
... ...
@@ -712,3 +712,130 @@ type AssetExtensionsConfig struct {
712 712
 	// Web Console's context root. Defaults to false.
713 713
 	HTML5Mode bool
714 714
 }
715
+
716
+type LDAPSyncConfig struct {
717
+	api.TypeMeta
718
+	// Host is the scheme, host and port of the LDAP server to connect to:
719
+	// scheme://host:port
720
+	Host string
721
+	// BindDN is an optional DN to bind with during the search phase.
722
+	BindDN string
723
+	// BindPassword is an optional password to bind with during the search phase.
724
+	BindPassword string
725
+	// Insecure, if true, indicates the connection should not use TLS.
726
+	// Cannot be set to true with a URL scheme of "ldaps://"
727
+	// If false, "ldaps://" URLs connect using TLS, and "ldap://" URLs are upgraded to a TLS connection using StartTLS as specified in https://tools.ietf.org/html/rfc2830
728
+	Insecure bool
729
+	// CA is the optional trusted certificate authority bundle to use when making requests to the server
730
+	// If empty, the default system roots are used
731
+	CA string
732
+
733
+	// LDAPGroupUIDToOpenShiftGroupNameMapping is an optional direct mapping of LDAP group UIDs to
734
+	// OpenShift Group names
735
+	LDAPGroupUIDToOpenShiftGroupNameMapping map[string]string
736
+
737
+	// LDAPSchemaSpecificConfig holds the configuration for retrieving data from the LDAP server.
738
+	// This set of configuration varies with LDAP server schema.
739
+	LDAPSchemaSpecificConfig
740
+}
741
+
742
+// LDAPSchemaSpecificConfig holds the schema-specific configuration for data retrieval from the LDAP
743
+// server. Only one of the members can be specified.
744
+type LDAPSchemaSpecificConfig struct {
745
+	// RFC2307Config holds the configuration for extracting data from an LDAP server set up in a fashion
746
+	// similar to RFC2307: first-class group and user entries, with group membership determined by a
747
+	// multi-valued attribute on the group entry listing its' members
748
+	RFC2307Config *RFC2307Config
749
+
750
+	// ActiveDirectoryConfig holds the configuration for extracting data from an LDAP server set up in a
751
+	// fashion similar to that used in Active Directory: first-class user entries, with group membership
752
+	// determined by a multi-valued attribute on members listing groups they are a member of
753
+	ActiveDirectoryConfig *ActiveDirectoryConfig
754
+
755
+	// AugmentedActiveDirectoryConfig holds the configuration for extracting data from an LDAP server
756
+	// set up in a fashion similar to that used in Active Directory as described above, with one addition:
757
+	// first-class group entries exist and are used to hold metadata but not group membership
758
+	AugmentedActiveDirectoryConfig *AugmentedActiveDirectoryConfig
759
+}
760
+
761
+type RFC2307Config struct {
762
+	// GroupQuery holds the template for an LDAP query that returns group entries
763
+	GroupQuery LDAPQuery
764
+
765
+	// GroupNameAttributes defines which attributes on an LDAP group entry will be interpreted as its' name
766
+	GroupNameAttributes []string
767
+
768
+	// GroupMembershipAttributes defines which attributes on an LDAP group entry will be interpreted
769
+	// as its' members
770
+	GroupMembershipAttributes []string
771
+
772
+	// UserQuery holds the template for an LDAP query that returns user entries
773
+	UserQuery LDAPQuery
774
+
775
+	// UserNameAttributes defines which attributes on an LDAP user entry will be interpreted as its' name
776
+	UserNameAttributes []string
777
+}
778
+
779
+type ActiveDirectoryConfig struct {
780
+	// UsersQuery holds the template for an LDAP query that returns all user entries
781
+	// that are labelled as being members of a group
782
+	UsersQuery LDAPQuery
783
+
784
+	// UserNameAttributes defines which attributes on an LDAP user entry will be interpreted as its' name
785
+	UserNameAttributes []string
786
+
787
+	// GroupMembershipAttributes defines which attributes on an LDAP user entry will be interpreted
788
+	// as the groups it is a member of
789
+	GroupMembershipAttributes []string
790
+}
791
+
792
+type AugmentedActiveDirectoryConfig struct {
793
+	// GroupQuery holds the template for an LDAP query that returns group entries
794
+	GroupQuery LDAPQuery
795
+
796
+	// GroupNameAttributes defines which attributes on an LDAP group entry will be interpreted as its' name
797
+	GroupNameAttributes []string
798
+
799
+	// UsersQuery holds the template for an LDAP query that returns all user entries
800
+	// that are labelled as being members of a group
801
+	UsersQuery LDAPQuery
802
+
803
+	// UserNameAttributes defines which attributes on an LDAP user entry will be interpreted as its' name
804
+	UserNameAttributes []string
805
+
806
+	// GroupMembershipAttributes defines which attributes on an LDAP user entry will be interpreted
807
+	// as the groups it is a member of
808
+	GroupMembershipAttributes []string
809
+}
810
+
811
+type LDAPQuery struct {
812
+	// The DN of the branch of the directory where all searches should start from
813
+	BaseDN string
814
+
815
+	// The (optional) scope of the search. Can be:
816
+	// base: only the base object,
817
+	// one:  all object on the base level,
818
+	// sub:  the entire subtree
819
+	// Defaults to the entire subtree if not set
820
+	Scope string
821
+
822
+	// The (optional) behavior of the search with regards to alisases. Can be:
823
+	// never:  never dereference aliases,
824
+	// search: only dereference in searching,
825
+	// base:   only dereference in finding the base object,
826
+	// always: always dereference
827
+	// Defaults to always dereferencing if not set
828
+	DerefAliases string
829
+
830
+	// TimeLimit holds the limit of time in seconds that any request to the server can remain outstanding
831
+	// before the wait for a response is given up. If this is 0, no client-side limit is imposed
832
+	TimeLimit int
833
+
834
+	// Filter is a valid LDAP search filter that retrieves all relevant entries from the LDAP server with the base DN
835
+	Filter string
836
+
837
+	// QueryAttribute is the attribute for a specific filter that, when conjoined with the common filter,
838
+	// retrieves the specific LDAP entry from the LDAP server. (e.g. "cn", when formatted with "aGroupName"
839
+	// and conjoined with "objectClass=groupOfNames", becomes (&(objectClass=groupOfNames)(cn=aGroupName))")
840
+	QueryAttribute string
841
+}
... ...
@@ -1,6 +1,7 @@
1 1
 package v1
2 2
 
3 3
 import (
4
+	"k8s.io/kubernetes/pkg/api"
4 5
 	"k8s.io/kubernetes/pkg/api/v1"
5 6
 	"k8s.io/kubernetes/pkg/runtime"
6 7
 )
... ...
@@ -696,3 +697,128 @@ type AssetExtensionsConfig struct {
696 696
 	// Web Console's context root. Defaults to false.
697 697
 	HTML5Mode bool `json:"html5Mode"`
698 698
 }
699
+
700
+type LDAPSyncConfig struct {
701
+	api.TypeMeta `json:",inline"`
702
+	// Host is the scheme, host and port of the LDAP server to connect to:
703
+	// scheme://host:port
704
+	Host string `json:"host" description:"scheme://host:port for the LDAP server"`
705
+	// BindDN is an optional DN to bind to the LDAP server with
706
+	BindDN string `json:"bindDN,omitempty" description:"the optional DN to bind with"`
707
+	// BindPassword is an optional password to bind with during the search phase.
708
+	BindPassword string `json:"bindPassword,omitempty" description:"the optional password to bind with"`
709
+	// Insecure, if true, indicates the connection should not use TLS.
710
+	// Cannot be set to true with a URL scheme of "ldaps://"
711
+	// If false, "ldaps://" URLs connect using TLS, and "ldap://" URLs are upgraded to a TLS connection using StartTLS as specified in https://tools.ietf.org/html/rfc2830
712
+	Insecure bool `json:"insecure" description:"specifies that the connection with the server should not use TLS"`
713
+	// CA is the optional trusted certificate authority bundle to use when making requests to the server
714
+	// If empty, the default system roots are used
715
+	CA string `json:"CA,omitempty" description:"an optional trusted CA to use when making requests to the server"`
716
+
717
+	// LDAPGroupUIDToOpenShiftGroupNameMapping is an optional direct mapping of LDAP group UIDs to
718
+	// OpenShift Group names
719
+	LDAPGroupUIDToOpenShiftGroupNameMapping map[string]string
720
+
721
+	// LDAPSchemaSpecificConfig holds the configuration for retrieving data from the LDAP server.
722
+	// This set of configuration varies with LDAP server schema.
723
+	LDAPSchemaSpecificConfig `json:"inline,omitempty" description:"schema-specific LDAP client configuration"`
724
+}
725
+
726
+// LDAPSchemaSpecificConfig holds the schema-specific configuration for data retrieval from the LDAP
727
+// server. Only one of the members can be specified.
728
+type LDAPSchemaSpecificConfig struct {
729
+	// RFC2307Config holds the configuration for extracting data from an LDAP server set up in a fashion
730
+	// similar to RFC2307: first-class group and user entries, with group membership determined by a
731
+	// multi-valued attribute on the group entry listing its' members
732
+	RFC2307Config *RFC2307Config `json:"RFC2307,omitempty" description:"schema-specific information for an RFC2307-like schema"`
733
+
734
+	// ActiveDirectoryConfig holds the configuration for extracting data from an LDAP server set up in a
735
+	// fashion similar to that used in Active Directory: first-class user entries, with group membership
736
+	// determined by a multi-valued attribute on members listing groups they are a member of
737
+	ActiveDirectoryConfig *ActiveDirectoryConfig `json:"activeDirectory,omitempty" description:"schema-specific information for an Active Directory-like schema"`
738
+
739
+	// AugmentedActiveDirectoryConfig holds the configuration for extracting data from an LDAP server
740
+	// set up in a fashion similar to that used in Active Directory as described above, with one addition:
741
+	// first-class group entries exist and are used to hold metadata but not group membership
742
+	AugmentedActiveDirectoryConfig *AugmentedActiveDirectoryConfig `json:"augmentedAD,omitempty" description:"schema-specific information for an Active Directory-like schema with group metadata entries"`
743
+}
744
+
745
+type RFC2307Config struct {
746
+	// GroupQuery holds the template for an LDAP query that returns group entries
747
+	GroupQuery LDAPQuery `json:"groupQuery" description:"the query for a group entry"`
748
+
749
+	// GroupNameAttributes defines which attributes on an LDAP group entry will be interpreted as its' name
750
+	GroupNameAttributes []string `json:"groupName" description:"the group name attributes"`
751
+
752
+	// GroupMembershipAttributes defines which attributes on an LDAP group entry will be interpreted
753
+	// as its' members
754
+	GroupMembershipAttributes []string `json:"groupMembership" description:"the group membership attributes"`
755
+
756
+	// UserQuery holds the template for an LDAP query that returns user entries
757
+	UserQuery LDAPQuery `json:"userQuery" description:"the query for a user entry"`
758
+
759
+	// UserNameAttributes defines which attributes on an LDAP user entry will be interpreted as its' name
760
+	UserNameAttributes []string `json:"userName" description:"the user name attributes"`
761
+}
762
+
763
+type ActiveDirectoryConfig struct {
764
+	// UsersQuery holds the template for an LDAP query that returns all user entries that are members of a group
765
+	UsersQuery LDAPQuery `json:"userQuery" description:"the query for all user entries that are members of a group"`
766
+
767
+	// UserNameAttributes defines which attributes on an LDAP user entry will be interpreted as its' name
768
+	UserNameAttributes []string `json:"userName" description:"the user name attributes"`
769
+
770
+	// GroupMembershipAttributes defines which attributes on an LDAP user entry will be interpreted
771
+	// as the groups it is a member of
772
+	GroupMembershipAttributes []string `json:"groupMembership" description:"the group membership attributes"`
773
+}
774
+
775
+type AugmentedActiveDirectoryConfig struct {
776
+	// GroupQuery holds the template for an LDAP query that returns group entries
777
+	GroupQuery LDAPQuery `json:"groupQuery" description:"the query for a group entry"`
778
+
779
+	// GroupNameAttributes defines which attributes on an LDAP group entry will be interpreted as its' name
780
+	GroupNameAttributes []string `json:"groupName" description:"the group name attributes"`
781
+
782
+	// UserQuery holds the template for an LDAP query that returns user entries
783
+	UserQuery LDAPQuery `json:"userQuery" description:"the query for a user entry"`
784
+
785
+	// UserNameAttributes defines which attributes on an LDAP user entry will be interpreted as its' name
786
+	UserNameAttributes []string `json:"userName" description:"the user name attributes"`
787
+
788
+	// GroupMembershipAttributes defines which attributes on an LDAP user entry will be interpreted
789
+	// as the groups it is a member of
790
+	GroupMembershipAttributes []string `json:"groupMembership" description:"the group membership attributes"`
791
+}
792
+
793
+type LDAPQuery struct {
794
+	// The DN of the branch of the directory where all searches should start from
795
+	BaseDN string `json:"baseDN" description:"the base DN for the search"`
796
+
797
+	// The (optional) scope of the search. Can be:
798
+	// base: only the base object,
799
+	// one:  all object on the base level,
800
+	// sub:  the entire subtree
801
+	// Defaults to the entire subtree if not set
802
+	Scope string `json:"scope" description:"the scope of the search"`
803
+
804
+	// The (optional) behavior of the search with regards to alisases. Can be:
805
+	// never:  never dereference aliases,
806
+	// search: only dereference in searching,
807
+	// base:   only dereference in finding the base object,
808
+	// always: always dereference
809
+	// Defaults to always dereferencing if not set
810
+	DerefAliases string `json:"derefAliases" description:"the alias dereferencing behavior"`
811
+
812
+	// TimeLimit holds the limit of time in seconds that any request to the server can remain outstanding
813
+	// before the wait for a response is given up. If this is 0, no client-side limit is imposed
814
+	TimeLimit int `json:"timeout" description:"the time limit for the query"`
815
+
816
+	// Filter is a valid LDAP search filter that retrieves all relevant entries from the LDAP server with the base DN
817
+	Filter string `json:"filter" description:"a valid LDAP filter for the query"`
818
+
819
+	// QueryAttribute is the attribute for a filter that, when conjoined with the filter, retrieves the
820
+	// specific LDAP entry from the LDAP server. (e.g. "cn", when formatted with "aGroupName" and conjoined
821
+	// with "objectClass=groupOfNames", becomes (&(objectClass=groupOfNames)(cn=aGroupName))")
822
+	QueryAttribute string `json:"queryAttribute" description:"the attribute to query on"`
823
+}
... ...
@@ -1,21 +1,21 @@
1 1
 package v1
2 2
 
3 3
 import (
4
-	"testing"
4
+  "testing"
5 5
 
6
-	"github.com/ghodss/yaml"
7
-	"k8s.io/kubernetes/pkg/runtime"
8
-	"k8s.io/kubernetes/pkg/util"
6
+  "github.com/ghodss/yaml"
7
+  "k8s.io/kubernetes/pkg/runtime"
8
+  "k8s.io/kubernetes/pkg/util"
9 9
 
10
-	internal "github.com/openshift/origin/pkg/cmd/server/api"
10
+  internal "github.com/openshift/origin/pkg/cmd/server/api"
11 11
 )
12 12
 
13 13
 const (
14
-	// This constant lists all possible options for the node config file in v1
15
-	// Before modifying this constant, ensure any changes have corresponding issues filed for:
16
-	// - documentation: https://github.com/openshift/openshift-docs/
17
-	// - install: https://github.com/openshift/openshift-ansible/
18
-	expectedSerializedNodeConfig = `allowDisabledDocker: false
14
+  // This constant lists all possible options for the node config file in v1
15
+  // Before modifying this constant, ensure any changes have corresponding issues filed for:
16
+  // - documentation: https://github.com/openshift/openshift-docs/
17
+  // - install: https://github.com/openshift/openshift-ansible/
18
+  expectedSerializedNodeConfig = `allowDisabledDocker: false
19 19
 apiVersion: v1
20 20
 dnsDomain: ""
21 21
 dnsIP: ""
... ...
@@ -43,12 +43,12 @@ servingInfo:
43 43
 volumeDirectory: ""
44 44
 `
45 45
 
46
-	// This constant lists all possible options for the master config file in v1.
47
-	// It also includes the fields for all the identity provider types.
48
-	// Before modifying this constant, ensure any changes have corresponding issues filed for:
49
-	// - documentation: https://github.com/openshift/openshift-docs/
50
-	// - install: https://github.com/openshift/openshift-ansible/
51
-	expectedSerializedMasterConfig = `apiLevels: null
46
+  // This constant lists all possible options for the master config file in v1.
47
+  // It also includes the fields for all the identity provider types.
48
+  // Before modifying this constant, ensure any changes have corresponding issues filed for:
49
+  // - documentation: https://github.com/openshift/openshift-docs/
50
+  // - install: https://github.com/openshift/openshift-ansible/
51
+  expectedSerializedMasterConfig = `apiLevels: null
52 52
 apiVersion: v1
53 53
 assetConfig:
54 54
   extensionDevelopment: false
... ...
@@ -265,58 +265,58 @@ servingInfo:
265 265
 )
266 266
 
267 267
 func TestNodeConfig(t *testing.T) {
268
-	config := &internal.NodeConfig{
269
-		PodManifestConfig: &internal.PodManifestConfig{},
270
-	}
271
-	serializedConfig, err := writeYAML(config)
272
-	if err != nil {
273
-		t.Fatal(err)
274
-	}
275
-	if string(serializedConfig) != expectedSerializedNodeConfig {
276
-		t.Errorf("Diff:\n-------------\n%s", util.StringDiff(string(serializedConfig), expectedSerializedNodeConfig))
277
-	}
268
+  config := &internal.NodeConfig{
269
+    PodManifestConfig: &internal.PodManifestConfig{},
270
+  }
271
+  serializedConfig, err := writeYAML(config)
272
+  if err != nil {
273
+    t.Fatal(err)
274
+  }
275
+  if string(serializedConfig) != expectedSerializedNodeConfig {
276
+    t.Errorf("Diff:\n-------------\n%s", util.StringDiff(string(serializedConfig), expectedSerializedNodeConfig))
277
+  }
278 278
 }
279 279
 
280 280
 func TestMasterConfig(t *testing.T) {
281
-	config := &internal.MasterConfig{
282
-		KubernetesMasterConfig: &internal.KubernetesMasterConfig{},
283
-		EtcdConfig:             &internal.EtcdConfig{},
284
-		OAuthConfig: &internal.OAuthConfig{
285
-			IdentityProviders: []internal.IdentityProvider{
286
-				{Provider: runtime.EmbeddedObject{Object: &internal.BasicAuthPasswordIdentityProvider{}}},
287
-				{Provider: runtime.EmbeddedObject{Object: &internal.AllowAllPasswordIdentityProvider{}}},
288
-				{Provider: runtime.EmbeddedObject{Object: &internal.DenyAllPasswordIdentityProvider{}}},
289
-				{Provider: runtime.EmbeddedObject{Object: &internal.HTPasswdPasswordIdentityProvider{}}},
290
-				{Provider: runtime.EmbeddedObject{Object: &internal.LDAPPasswordIdentityProvider{}}},
291
-				{Provider: runtime.EmbeddedObject{Object: &internal.RequestHeaderIdentityProvider{}}},
292
-				{Provider: runtime.EmbeddedObject{Object: &internal.GitHubIdentityProvider{}}},
293
-				{Provider: runtime.EmbeddedObject{Object: &internal.GoogleIdentityProvider{}}},
294
-				{Provider: runtime.EmbeddedObject{Object: &internal.OpenIDIdentityProvider{}}},
295
-			},
296
-			SessionConfig: &internal.SessionConfig{},
297
-		},
298
-		AssetConfig: &internal.AssetConfig{},
299
-		DNSConfig:   &internal.DNSConfig{},
300
-	}
301
-	serializedConfig, err := writeYAML(config)
302
-	if err != nil {
303
-		t.Fatal(err)
304
-	}
305
-	if string(serializedConfig) != expectedSerializedMasterConfig {
306
-		t.Errorf("Diff:\n-------------\n%s", util.StringDiff(string(serializedConfig), expectedSerializedMasterConfig))
307
-	}
281
+  config := &internal.MasterConfig{
282
+    KubernetesMasterConfig: &internal.KubernetesMasterConfig{},
283
+    EtcdConfig:             &internal.EtcdConfig{},
284
+    OAuthConfig: &internal.OAuthConfig{
285
+      IdentityProviders: []internal.IdentityProvider{
286
+        {Provider: runtime.EmbeddedObject{Object: &internal.BasicAuthPasswordIdentityProvider{}}},
287
+        {Provider: runtime.EmbeddedObject{Object: &internal.AllowAllPasswordIdentityProvider{}}},
288
+        {Provider: runtime.EmbeddedObject{Object: &internal.DenyAllPasswordIdentityProvider{}}},
289
+        {Provider: runtime.EmbeddedObject{Object: &internal.HTPasswdPasswordIdentityProvider{}}},
290
+        {Provider: runtime.EmbeddedObject{Object: &internal.LDAPPasswordIdentityProvider{}}},
291
+        {Provider: runtime.EmbeddedObject{Object: &internal.RequestHeaderIdentityProvider{}}},
292
+        {Provider: runtime.EmbeddedObject{Object: &internal.GitHubIdentityProvider{}}},
293
+        {Provider: runtime.EmbeddedObject{Object: &internal.GoogleIdentityProvider{}}},
294
+        {Provider: runtime.EmbeddedObject{Object: &internal.OpenIDIdentityProvider{}}},
295
+      },
296
+      SessionConfig: &internal.SessionConfig{},
297
+    },
298
+    AssetConfig: &internal.AssetConfig{},
299
+    DNSConfig:   &internal.DNSConfig{},
300
+  }
301
+  serializedConfig, err := writeYAML(config)
302
+  if err != nil {
303
+    t.Fatal(err)
304
+  }
305
+  if string(serializedConfig) != expectedSerializedMasterConfig {
306
+    t.Errorf("Diff:\n-------------\n%s", util.StringDiff(string(serializedConfig), expectedSerializedMasterConfig))
307
+  }
308 308
 
309 309
 }
310 310
 
311 311
 func writeYAML(obj runtime.Object) ([]byte, error) {
312
-	json, err := Codec.Encode(obj)
313
-	if err != nil {
314
-		return nil, err
315
-	}
312
+  json, err := Codec.Encode(obj)
313
+  if err != nil {
314
+    return nil, err
315
+  }
316 316
 
317
-	content, err := yaml.JSONToYAML(json)
318
-	if err != nil {
319
-		return nil, err
320
-	}
321
-	return content, err
317
+  content, err := yaml.JSONToYAML(json)
318
+  if err != nil {
319
+    return nil, err
320
+  }
321
+  return content, err
322 322
 }
... ...
@@ -9,7 +9,6 @@ import (
9 9
 	"k8s.io/kubernetes/pkg/util/fielderrors"
10 10
 
11 11
 	"github.com/openshift/origin/pkg/auth/authenticator/redirector"
12
-	"github.com/openshift/origin/pkg/auth/ldaputil"
13 12
 	"github.com/openshift/origin/pkg/auth/server/login"
14 13
 	"github.com/openshift/origin/pkg/cmd/server/api"
15 14
 	"github.com/openshift/origin/pkg/cmd/server/api/latest"
... ...
@@ -145,50 +144,18 @@ func ValidateIdentityProvider(identityProvider api.IdentityProvider) ValidationR
145 145
 }
146 146
 
147 147
 func ValidateLDAPIdentityProvider(provider *api.LDAPPasswordIdentityProvider) ValidationResults {
148
-	validationResults := ValidationResults{}
149
-
150
-	if len(provider.URL) == 0 {
151
-		validationResults.AddErrors(fielderrors.NewFieldRequired("provider.url"))
152
-		return validationResults
153
-	}
154
-
155
-	u, err := ldaputil.ParseURL(provider.URL)
156
-	if err != nil {
157
-		validationResults.AddErrors(fielderrors.NewFieldInvalid("provider.url", provider.URL, err.Error()))
158
-		return validationResults
159
-	}
160
-
161
-	// Make sure bindDN and bindPassword are both set, or both unset
162
-	// Both unset means an anonymous bind is used for search (https://tools.ietf.org/html/rfc4513#section-5.1.1)
163
-	// Both set means the name/password simple bind is used for search (https://tools.ietf.org/html/rfc4513#section-5.1.3)
164
-	if (len(provider.BindDN) == 0) != (len(provider.BindPassword) == 0) {
165
-		validationResults.AddErrors(fielderrors.NewFieldInvalid("provider.bindDN", provider.BindDN, "bindDN and bindPassword must both be specified, or both be empty"))
166
-		validationResults.AddErrors(fielderrors.NewFieldInvalid("provider.bindPassword", "<masked>", "bindDN and bindPassword must both be specified, or both be empty"))
167
-	}
168
-
169
-	if provider.Insecure {
170
-		if u.Scheme == ldaputil.SchemeLDAPS {
171
-			validationResults.AddErrors(fielderrors.NewFieldInvalid("provider.url", provider.URL, fmt.Sprintf("Cannot use %s scheme with insecure=true", u.Scheme)))
172
-		}
173
-		if len(provider.CA) > 0 {
174
-			validationResults.AddErrors(fielderrors.NewFieldInvalid("provider.ca", provider.CA, "Cannot specify a ca with insecure=true"))
175
-		}
176
-	} else {
177
-		if len(provider.CA) > 0 {
178
-			validationResults.AddErrors(ValidateFile(provider.CA, "provider.ca")...)
179
-		}
180
-	}
148
+	validationResults := ValidateLDAPClientConfig("provider",
149
+		provider.URL,
150
+		provider.BindDN,
151
+		provider.BindPassword,
152
+		provider.CA,
153
+		provider.Insecure)
181 154
 
182 155
 	// At least one attribute to use as the user id is required
183 156
 	if len(provider.Attributes.ID) == 0 {
184 157
 		validationResults.AddErrors(fielderrors.NewFieldInvalid("provider.attributes.id", "[]", "at least one id attribute is required (LDAP standard identity attribute is 'dn')"))
185 158
 	}
186 159
 
187
-	// Warn if insecure
188
-	if provider.Insecure {
189
-		validationResults.AddWarnings(fielderrors.NewFieldInvalid("provider.insecure", provider.Insecure, "validating passwords over an insecure connection could allow them to be intercepted"))
190
-	}
191
-
192 160
 	return validationResults
193 161
 }
194 162
 
... ...
@@ -7,12 +7,14 @@ import (
7 7
 	"os"
8 8
 	"strings"
9 9
 
10
+	"github.com/go-ldap/ldap"
10 11
 	"github.com/spf13/pflag"
11 12
 
12 13
 	kvalidation "k8s.io/kubernetes/pkg/api/validation"
13 14
 	"k8s.io/kubernetes/pkg/util"
14 15
 	"k8s.io/kubernetes/pkg/util/fielderrors"
15 16
 
17
+	"github.com/openshift/origin/pkg/auth/ldaputil"
16 18
 	"github.com/openshift/origin/pkg/cmd/server/api"
17 19
 	cmdflags "github.com/openshift/origin/pkg/cmd/util/flags"
18 20
 )
... ...
@@ -240,3 +242,191 @@ func ValidateExtendedArguments(config api.ExtendedArguments, flagFunc func(*pfla
240 240
 
241 241
 	return allErrs
242 242
 }
243
+
244
+func ValidateLDAPSyncConfig(config api.LDAPSyncConfig) ValidationResults {
245
+	validationResults := ValidateLDAPClientConfig("config",
246
+		config.Host,
247
+		config.BindDN,
248
+		config.BindPassword,
249
+		config.CA,
250
+		config.Insecure)
251
+
252
+	var numConfigs int
253
+
254
+	if config.RFC2307Config != nil {
255
+		configResults := ValidateRFC2307Config(config.RFC2307Config)
256
+		validationResults.AddErrors(configResults.Errors...)
257
+		validationResults.AddWarnings(configResults.Warnings...)
258
+		numConfigs++
259
+	}
260
+	if config.ActiveDirectoryConfig != nil {
261
+		configResults := ValidateActiveDirectoryConfig(config.ActiveDirectoryConfig)
262
+		validationResults.AddErrors(configResults.Errors...)
263
+		validationResults.AddWarnings(configResults.Warnings...)
264
+		numConfigs++
265
+	}
266
+	if config.AugmentedActiveDirectoryConfig != nil {
267
+		configResults := ValidateAugmentedActiveDirectoryConfig(config.AugmentedActiveDirectoryConfig)
268
+		validationResults.AddErrors(configResults.Errors...)
269
+		validationResults.AddWarnings(configResults.Warnings...)
270
+		numConfigs++
271
+	}
272
+	if numConfigs != 1 {
273
+		validationResults.AddErrors(fielderrors.NewFieldInvalid("", config.LDAPSchemaSpecificConfig,
274
+			"only one schema-specific config is allowed"))
275
+	}
276
+
277
+	return validationResults
278
+}
279
+
280
+func ValidateLDAPClientConfig(parent, url, bindDN, bindPassword, CA string, insecure bool) ValidationResults {
281
+	validationResults := ValidationResults{}
282
+
283
+	if len(url) == 0 {
284
+		validationResults.AddErrors(fielderrors.NewFieldRequired(parent + ".host"))
285
+		return validationResults
286
+	}
287
+
288
+	u, err := ldaputil.ParseURL(url)
289
+	if err != nil {
290
+		validationResults.AddErrors(fielderrors.NewFieldInvalid(parent+".URL", url, err.Error()))
291
+		return validationResults
292
+	}
293
+
294
+	// Make sure bindDN and bindPassword are both set, or both unset
295
+	// Both unset means an anonymous bind is used for search (https://tools.ietf.org/html/rfc4513#section-5.1.1)
296
+	// Both set means the name/password simple bind is used for search (https://tools.ietf.org/html/rfc4513#section-5.1.3)
297
+	if (len(bindDN) == 0) != (len(bindPassword) == 0) {
298
+		validationResults.AddErrors(fielderrors.NewFieldInvalid(parent+".bindDN", bindDN,
299
+			"bindDN and bindPassword must both be specified, or both be empty"))
300
+		validationResults.AddErrors(fielderrors.NewFieldInvalid(parent+".bindPassword", "<masked>",
301
+			"bindDN and bindPassword must both be specified, or both be empty"))
302
+	}
303
+
304
+	if insecure {
305
+		if u.Scheme == ldaputil.SchemeLDAPS {
306
+			validationResults.AddErrors(fielderrors.NewFieldInvalid(parent+".url", url,
307
+				fmt.Sprintf("Cannot use %s scheme with insecure=true", u.Scheme)))
308
+		}
309
+		if len(CA) > 0 {
310
+			validationResults.AddErrors(fielderrors.NewFieldInvalid(parent+".ca", CA,
311
+				"Cannot specify a ca with insecure=true"))
312
+		}
313
+	} else {
314
+		if len(CA) > 0 {
315
+			validationResults.AddErrors(ValidateFile(CA, parent+".ca")...)
316
+		}
317
+	}
318
+
319
+	// Warn if insecure
320
+	if insecure {
321
+		validationResults.AddWarnings(fielderrors.NewFieldInvalid(parent+".insecure", insecure,
322
+			"validating passwords over an insecure connection could allow them to be intercepted"))
323
+	}
324
+
325
+	return validationResults
326
+}
327
+
328
+func ValidateRFC2307Config(config *api.RFC2307Config) ValidationResults {
329
+	validationResults := ValidationResults{}
330
+
331
+	groupQueryResults := ValidateLDAPQuery("groupQuery", config.GroupQuery)
332
+	validationResults.AddErrors(groupQueryResults.Errors...)
333
+	validationResults.AddWarnings(groupQueryResults.Warnings...)
334
+
335
+	if len(config.GroupNameAttributes) == 0 {
336
+		validationResults.AddErrors(fielderrors.NewFieldRequired("groupName"))
337
+	}
338
+
339
+	if len(config.GroupMembershipAttributes) == 0 {
340
+		validationResults.AddErrors(fielderrors.NewFieldRequired("groupMembership"))
341
+	}
342
+
343
+	userQueryResults := ValidateLDAPQuery("userQuery", config.UserQuery)
344
+	validationResults.AddErrors(userQueryResults.Errors...)
345
+	validationResults.AddWarnings(userQueryResults.Warnings...)
346
+
347
+	if len(config.UserNameAttributes) == 0 {
348
+		validationResults.AddErrors(fielderrors.NewFieldRequired("userName"))
349
+	}
350
+
351
+	return validationResults
352
+}
353
+
354
+func ValidateActiveDirectoryConfig(config *api.ActiveDirectoryConfig) ValidationResults {
355
+	validationResults := ValidationResults{}
356
+
357
+	userQueryResults := ValidateLDAPQuery("usersQuery", config.UsersQuery)
358
+	validationResults.AddErrors(userQueryResults.Errors...)
359
+	validationResults.AddWarnings(userQueryResults.Warnings...)
360
+
361
+	if len(config.UserNameAttributes) == 0 {
362
+		validationResults.AddErrors(fielderrors.NewFieldRequired("userName"))
363
+	}
364
+
365
+	if len(config.GroupMembershipAttributes) == 0 {
366
+		validationResults.AddErrors(fielderrors.NewFieldRequired("groupMembership"))
367
+	}
368
+
369
+	return validationResults
370
+}
371
+
372
+func ValidateAugmentedActiveDirectoryConfig(config *api.AugmentedActiveDirectoryConfig) ValidationResults {
373
+	validationResults := ValidationResults{}
374
+
375
+	groupQueryResults := ValidateLDAPQuery("groupQuery", config.GroupQuery)
376
+	validationResults.AddErrors(groupQueryResults.Errors...)
377
+	validationResults.AddWarnings(groupQueryResults.Warnings...)
378
+
379
+	if len(config.GroupNameAttributes) == 0 {
380
+		validationResults.AddErrors(fielderrors.NewFieldRequired("groupName"))
381
+	}
382
+
383
+	if len(config.GroupMembershipAttributes) == 0 {
384
+		validationResults.AddErrors(fielderrors.NewFieldRequired("groupMembership"))
385
+	}
386
+
387
+	userQueryResults := ValidateLDAPQuery("usersQuery", config.UsersQuery)
388
+	validationResults.AddErrors(userQueryResults.Errors...)
389
+	validationResults.AddWarnings(userQueryResults.Warnings...)
390
+
391
+	if len(config.UserNameAttributes) == 0 {
392
+		validationResults.AddErrors(fielderrors.NewFieldRequired("userName"))
393
+	}
394
+	return validationResults
395
+}
396
+
397
+func ValidateLDAPQuery(queryName string, query api.LDAPQuery) ValidationResults {
398
+	validationResults := ValidationResults{}
399
+
400
+	if _, err := ldap.ParseDN(query.BaseDN); err != nil {
401
+		validationResults.AddErrors(fielderrors.NewFieldInvalid(queryName+".baseDN", query.BaseDN,
402
+			fmt.Sprintf("invalid base DN for search: %v", err)))
403
+	}
404
+
405
+	if len(query.Scope) > 0 {
406
+		if _, err := ldaputil.DetermineLDAPScope(query.Scope); err != nil {
407
+			validationResults.AddErrors(fielderrors.NewFieldInvalid(queryName+".scope", query.Scope,
408
+				"invalid LDAP search scope"))
409
+		}
410
+	}
411
+
412
+	if len(query.DerefAliases) > 0 {
413
+		if _, err := ldaputil.DetermineDerefAliasesBehavior(query.DerefAliases); err != nil {
414
+			validationResults.AddErrors(fielderrors.NewFieldInvalid(queryName+".derefAliases",
415
+				query.DerefAliases, "LDAP alias dereferencing instruction invalid"))
416
+		}
417
+	}
418
+
419
+	if query.TimeLimit < 0 {
420
+		validationResults.AddErrors(fielderrors.NewFieldInvalid(queryName+".timeout", query.TimeLimit,
421
+			"timeout must be equal to or greater than zero"))
422
+	}
423
+
424
+	if _, err := ldap.CompileFilter(query.Filter); err != nil {
425
+		validationResults.AddErrors(fielderrors.NewFieldInvalid(queryName+".filter", query.Filter,
426
+			fmt.Sprintf("invalid query filter: %v", err)))
427
+	}
428
+
429
+	return validationResults
430
+}
... ...
@@ -478,21 +478,18 @@ func (c *AuthConfig) getPasswordAuthenticator(identityProvider configapi.Identit
478 478
 			return nil, fmt.Errorf("Error parsing LDAPPasswordIdentityProvider URL: %v", err)
479 479
 		}
480 480
 
481
-		tlsConfig := &tls.Config{}
482
-		if len(provider.CA) > 0 {
483
-			roots, err := util.CertPoolFromFile(provider.CA)
484
-			if err != nil {
485
-				return nil, fmt.Errorf("error loading cert pool from ca file %s: %v", provider.CA, err)
486
-			}
487
-			tlsConfig.RootCAs = roots
481
+		clientConfig, err := ldaputil.NewLDAPClientConfig(provider.URL,
482
+			provider.BindDN,
483
+			provider.BindPassword,
484
+			provider.CA,
485
+			provider.Insecure)
486
+		if err != nil {
487
+			return nil, err
488 488
 		}
489 489
 
490 490
 		opts := ldappassword.Options{
491
-			URL:          url,
492
-			ClientConfig: ldaputil.NewLDAPClientConfig(url, provider.Insecure, tlsConfig),
493
-			BindDN:       provider.BindDN,
494
-			BindPassword: provider.BindPassword,
495
-
491
+			URL:                  url,
492
+			ClientConfig:         clientConfig,
496 493
 			UserAttributeDefiner: ldaputil.NewLDAPUserAttributeDefiner(provider.Attributes),
497 494
 		}
498 495
 		return ldappassword.New(identityProvider.Name, opts, identityMapper)
499 496
new file mode 100644
... ...
@@ -0,0 +1,434 @@
0
+package authentication
1
+
2
+import (
3
+	"fmt"
4
+	"os"
5
+	"reflect"
6
+	"regexp"
7
+	"strings"
8
+
9
+	g "github.com/onsi/ginkgo"
10
+	o "github.com/onsi/gomega"
11
+
12
+	kapi "k8s.io/kubernetes/pkg/api"
13
+	"k8s.io/kubernetes/pkg/fields"
14
+	"k8s.io/kubernetes/pkg/labels"
15
+
16
+	"github.com/openshift/origin/pkg/auth/ldaputil"
17
+	"github.com/openshift/origin/pkg/client"
18
+	"github.com/openshift/origin/pkg/cmd/experimental/syncgroups"
19
+	configapi "github.com/openshift/origin/pkg/cmd/server/api"
20
+	userapi "github.com/openshift/origin/pkg/user/api"
21
+	exutil "github.com/openshift/origin/test/extended/util"
22
+)
23
+
24
+var _ = g.Describe("authentication: OpenLDAP build and deployment", func() {
25
+	defer g.GinkgoRecover()
26
+	var (
27
+		imageStreamFixture       = exutil.FixturePath("fixtures", "ldap", "ldapserver-imagestream.json")
28
+		imageStreamTargetFixture = exutil.FixturePath("fixtures", "ldap", "ldapserver-imagestream-testenv.json")
29
+		buildConfigFixture       = exutil.FixturePath("fixtures", "ldap", "ldapserver-buildconfig.json")
30
+		deploymentConfigFixture  = exutil.FixturePath("fixtures", "ldap", "ldapserver-deploymentconfig.json")
31
+		serviceConfigFixture     = exutil.FixturePath("fixtures", "ldap", "ldapserver-service.json")
32
+		oc                       = exutil.NewCLI("openldap", exutil.KubeConfigPath())
33
+	)
34
+
35
+	g.Describe("Building and deploying an OpenLDAP server", func() {
36
+		g.It(fmt.Sprintf("should create a image from %s template and run it in a pod", buildConfigFixture), func() {
37
+			nameRegex := regexp.MustCompile(`"[A-Za-z0-9\-]+"`)
38
+			oc.SetOutputDir(exutil.TestContext.OutputDir)
39
+
40
+			g.By(fmt.Sprintf("calling oc create -f %s", imageStreamFixture))
41
+			imageStreamMessage, err := oc.Run("create").Args("-f", imageStreamFixture).Output()
42
+			o.Expect(err).NotTo(o.HaveOccurred())
43
+
44
+			imageStreamName := strings.Trim(nameRegex.FindString(imageStreamMessage), `"`)
45
+			g.By("expecting the imagestream to fetch and tag the latest image")
46
+			err = exutil.WaitForAnImageStream(oc.REST().ImageStreams(oc.Namespace()), imageStreamName,
47
+				exutil.CheckImageStreamLatestTagPopulatedFunc, exutil.CheckImageStreamTagNotFoundFunc)
48
+			o.Expect(err).NotTo(o.HaveOccurred())
49
+
50
+			g.By(fmt.Sprintf("calling oc create -f %s", imageStreamTargetFixture))
51
+			err = oc.Run("create").Args("-f", imageStreamTargetFixture).Execute()
52
+			o.Expect(err).NotTo(o.HaveOccurred())
53
+
54
+			g.By(fmt.Sprintf("calling oc create -f %s", buildConfigFixture))
55
+			buildConfigMessage, err := oc.Run("create").Args("-f", buildConfigFixture).Output()
56
+			o.Expect(err).NotTo(o.HaveOccurred())
57
+
58
+			buildConfigName := strings.Trim(nameRegex.FindString(buildConfigMessage), `"`)
59
+			g.By(fmt.Sprintf("calling oc start-build %s", buildConfigName))
60
+			buildName, err := oc.Run("start-build").Args(buildConfigName).Output()
61
+			o.Expect(err).NotTo(o.HaveOccurred())
62
+
63
+			g.By("expecting the build to be in Complete phase")
64
+			err = exutil.WaitForABuild(oc.REST().Builds(oc.Namespace()), buildName,
65
+				exutil.CheckBuildSuccessFunc, exutil.CheckBuildFailedFunc)
66
+			o.Expect(err).NotTo(o.HaveOccurred())
67
+
68
+			g.By(fmt.Sprintf("calling oc create -f %s", deploymentConfigFixture))
69
+			deploymentConfigMessage, err := oc.Run("create").Args("-f", deploymentConfigFixture).Output()
70
+			o.Expect(err).NotTo(o.HaveOccurred())
71
+
72
+			deploymentConfigName := strings.Trim(nameRegex.FindString(deploymentConfigMessage), `"`)
73
+			g.By(fmt.Sprintf("calling oc deploy %s", deploymentConfigName))
74
+			err = oc.Run("deploy").Args(deploymentConfigName).Execute()
75
+			o.Expect(err).NotTo(o.HaveOccurred())
76
+
77
+			g.By("expecting the deployment to be in Complete phase")
78
+			err = exutil.WaitForADeployment(oc.KubeREST().ReplicationControllers(oc.Namespace()), deploymentConfigName,
79
+				exutil.CheckDeploymentCompletedFunc, exutil.CheckDeploymentFailedFunc)
80
+			o.Expect(err).NotTo(o.HaveOccurred())
81
+
82
+			g.By(fmt.Sprintf("calling oc create -f %s", serviceConfigFixture))
83
+			err = oc.Run("create").Args("-f", serviceConfigFixture).Execute()
84
+			o.Expect(err).NotTo(o.HaveOccurred())
85
+
86
+			client := oc.KubeREST().Services(oc.Namespace())
87
+			ldapService, err := client.Get("openldap-server")
88
+
89
+			var testCases = []struct {
90
+				name       string
91
+				options    syncgroups.SyncGroupsOptions
92
+				expected   []string
93
+				seedGroups []userapi.Group //allows for groups to exist prior to the sync
94
+				preSync    bool            //determines whether a sync should be performed before the sync to be tested
95
+			}{
96
+				{
97
+					name: "schema 1 all ldap",
98
+					options: syncgroups.SyncGroupsOptions{
99
+						Source: syncgroups.GroupSyncSourceLDAP,
100
+						Scope:  syncgroups.GroupSyncScopeAll,
101
+					},
102
+					expected:   []string{GroupName1, GroupName2, GroupName3},
103
+					seedGroups: []userapi.Group{},
104
+					preSync:    false,
105
+				},
106
+				{
107
+					name: "schema 1 whitelist LDAP",
108
+					options: syncgroups.SyncGroupsOptions{
109
+						Source:            syncgroups.GroupSyncSourceLDAP,
110
+						Scope:             syncgroups.GroupSyncScopeWhitelist,
111
+						WhitelistContents: []string{GroupName1, GroupName2},
112
+					},
113
+					expected:   []string{GroupName1, GroupName2},
114
+					seedGroups: []userapi.Group{},
115
+					preSync:    false,
116
+				},
117
+				{
118
+					name: "schema 1 all openshift no previous sync",
119
+					options: syncgroups.SyncGroupsOptions{
120
+						Source: syncgroups.GroupSyncSourceOpenShift,
121
+						Scope:  syncgroups.GroupSyncScopeAll,
122
+					},
123
+					expected:   []string{}, // cant sync OpenShift groups that haven't been linked to an LDAP entry
124
+					seedGroups: []userapi.Group{},
125
+					preSync:    false,
126
+				},
127
+				{
128
+					name: "schema 1 all openshift with previous sync",
129
+					options: syncgroups.SyncGroupsOptions{
130
+						Source: syncgroups.GroupSyncSourceOpenShift,
131
+						Scope:  syncgroups.GroupSyncScopeAll,
132
+					},
133
+					expected:   []string{GroupName1, GroupName2, GroupName3},
134
+					seedGroups: []userapi.Group{},
135
+					preSync:    true,
136
+				},
137
+				{
138
+					name: "schema 1 whitelist openshift no previous sync",
139
+					options: syncgroups.SyncGroupsOptions{
140
+						Source:            syncgroups.GroupSyncSourceOpenShift,
141
+						Scope:             syncgroups.GroupSyncScopeWhitelist,
142
+						WhitelistContents: []string{GroupName1, GroupName2},
143
+					},
144
+					expected:   []string{}, // cant sync OpenShift groups that haven't been linked to an LDAP entry
145
+					seedGroups: []userapi.Group{},
146
+					preSync:    false,
147
+				},
148
+				{
149
+					name: "schema 1 whitelist openshift with previous sync",
150
+					options: syncgroups.SyncGroupsOptions{
151
+						Source:            syncgroups.GroupSyncSourceOpenShift,
152
+						Scope:             syncgroups.GroupSyncScopeWhitelist,
153
+						WhitelistContents: []string{GroupName1, GroupName2},
154
+					},
155
+					expected:   []string{GroupName1, GroupName2},
156
+					seedGroups: []userapi.Group{},
157
+					preSync:    true,
158
+				},
159
+				// TODO: seed a group that shares name but has not been synced, check for Existing correctness
160
+			}
161
+
162
+			for _, testCase := range testCases {
163
+				g.By(fmt.Sprintf("Running test case: %s", testCase.name))
164
+				// determine LDAP server host:port
165
+				host := ldapService.Spec.ClusterIP + ":389"
166
+
167
+				// determine expected groups
168
+				expectedGroups := makeGroups(host, testCase.expected)
169
+
170
+				// populate config with test-case data
171
+				testCase.options.Config = makeConfig(host)
172
+				testCase.options.GroupInterface = oc.AdminREST().Groups()
173
+				testCase.options.Stderr = os.Stderr
174
+				testCase.options.Out = os.Stdout
175
+
176
+				// Check that we are in the correct starting state
177
+				g.By("Checking that the test case starts in the correct state")
178
+				groupList, err := oc.AdminREST().Groups().List(labels.Everything(), fields.Everything())
179
+				o.Expect(err).NotTo(o.HaveOccurred())
180
+
181
+				var stateErr error
182
+				if len(groupList.Items) != 0 {
183
+					stateErr = fmt.Errorf("test %s beginning in incorrect state: should have no groups, had: %d, (%v)",
184
+						testCase.name, len(groupList.Items), groupList.Items)
185
+				}
186
+				o.Expect(stateErr).NotTo(o.HaveOccurred())
187
+
188
+				// Add groups if necessary
189
+				g.By("Adding seed groups as necessary")
190
+				for _, groupToAdd := range testCase.seedGroups {
191
+					_, err = oc.AdminREST().Groups().Create(&groupToAdd)
192
+					o.Expect(err).NotTo(o.HaveOccurred())
193
+				}
194
+
195
+				// Preform "pre-sync" if required - this allows for OpenShift - sourced sync jobs to work
196
+				// the OpenShift - sourced GroupListers look for the LDAPURLAnnotation annotation as well as the LDAPUIDAnnotation annotation
197
+				g.By("Performing the pre-sync")
198
+				if testCase.preSync {
199
+					for _, group := range expectedGroups {
200
+						bareGroup := createBareGroup(group)
201
+						_, err = oc.AdminREST().Groups().Create(&bareGroup)
202
+						o.Expect(err).NotTo(o.HaveOccurred())
203
+					}
204
+				}
205
+
206
+				// Perform sync job
207
+				g.By("Performing the sync job")
208
+				errs := testCase.options.Run()
209
+				o.Expect(errs).NotTo(o.HaveOccurred())
210
+
211
+				// Check that the results are what we expected
212
+				g.By("Validating results")
213
+				newGroupList, err := oc.AdminREST().Groups().List(labels.Everything(), fields.Everything())
214
+				o.Expect(err).NotTo(o.HaveOccurred())
215
+
216
+				ok, err := checkSetEquality(newGroupList.Items, expectedGroups)
217
+				if err != nil || !ok {
218
+					stateErr = fmt.Errorf("group sync ended in incorrect state after test %s: %v", testCase.name, err)
219
+				}
220
+				o.Expect(stateErr).NotTo(o.HaveOccurred())
221
+
222
+				// Clean up OpenShift etcd Group records
223
+				cleanup(oc.AdminREST())
224
+			}
225
+		})
226
+	})
227
+})
228
+
229
+const (
230
+	LDAPScopeWholeSubtree string = "sub"
231
+	LDAPNeverDerefAliases string = "never"
232
+	LDAPQueryTimeout      int    = 10
233
+
234
+	BaseDN      string = "dc=example,dc=com"
235
+	GroupBaseDN string = "ou=groups," + BaseDN
236
+	UserBaseDN  string = "ou=people," + BaseDN
237
+
238
+	GroupFilter         string = "objectClass=groupOfNames"
239
+	GroupQueryAttribute string = "cn"
240
+	UserFilter          string = "objectClass=inetOrgPerson"
241
+	UserQueryAttribute  string = "cn"
242
+
243
+	GroupMembershipAttribute string = "member"
244
+
245
+	GroupNameAttribute1 string = "missing"
246
+	GroupNameAttribute2 string = "cn"
247
+
248
+	UserNameAttribute1 string = "missing"
249
+	UserNameAttribute2 string = "name"
250
+	UserNameAttribute3 string = "cn"
251
+
252
+	GroupName1 string = "group1"
253
+	GroupName2 string = "group2"
254
+	GroupName3 string = "group3"
255
+
256
+	UserName1 string = "Person1"
257
+	UserName2 string = "Person2"
258
+	UserName3 string = "Person3"
259
+	UserName4 string = "Person4"
260
+	UserName5 string = "Person5"
261
+)
262
+
263
+// makeGroups injects the run-dependent host into the expected group records and returns those
264
+// specified by the which string array
265
+func makeGroups(host string, which []string) []userapi.Group {
266
+	GroupRecord1 := userapi.Group{
267
+		ObjectMeta: kapi.ObjectMeta{
268
+			Name:      GroupName1,
269
+			Namespace: "",
270
+			Annotations: map[string]string{
271
+				ldaputil.LDAPURLAnnotation: host,
272
+				ldaputil.LDAPUIDAnnotation: GroupName1,
273
+			},
274
+		},
275
+		Users: []string{
276
+			UserName1,
277
+			UserName2,
278
+			UserName3,
279
+			UserName4,
280
+			UserName5,
281
+		},
282
+	}
283
+
284
+	GroupRecord2 := userapi.Group{
285
+		ObjectMeta: kapi.ObjectMeta{
286
+			Name:      GroupName2,
287
+			Namespace: "",
288
+			Annotations: map[string]string{
289
+				ldaputil.LDAPURLAnnotation: host,
290
+				ldaputil.LDAPUIDAnnotation: GroupName2,
291
+			},
292
+		},
293
+		Users: []string{
294
+			UserName1,
295
+			UserName2,
296
+			UserName3,
297
+		},
298
+	}
299
+
300
+	GroupRecord3 := userapi.Group{
301
+		ObjectMeta: kapi.ObjectMeta{
302
+			Name:      GroupName3,
303
+			Namespace: "",
304
+			Annotations: map[string]string{
305
+				ldaputil.LDAPURLAnnotation: host,
306
+				ldaputil.LDAPUIDAnnotation: GroupName3,
307
+			},
308
+		},
309
+		Users: []string{
310
+			UserName1,
311
+			UserName5,
312
+		},
313
+	}
314
+
315
+	expectedGroups := []userapi.Group{}
316
+	for _, expectedGroup := range which {
317
+		switch expectedGroup {
318
+		case GroupName3:
319
+			expectedGroups = append(expectedGroups, GroupRecord3)
320
+		case GroupName2:
321
+			expectedGroups = append(expectedGroups, GroupRecord2)
322
+		case GroupName1:
323
+			expectedGroups = append(expectedGroups, GroupRecord1)
324
+		}
325
+	}
326
+
327
+	return expectedGroups
328
+}
329
+
330
+func makeConfig(host string) configapi.LDAPSyncConfig {
331
+	// hard-coded config until config-file parsing is hashed out
332
+	return configapi.LDAPSyncConfig{
333
+		Host:         "ldap://" + host + "/",
334
+		BindDN:       "",
335
+		BindPassword: "",
336
+		Insecure:     true,
337
+		CA:           "",
338
+
339
+		LDAPGroupUIDToOpenShiftGroupNameMapping: make(map[string]string),
340
+
341
+		LDAPSchemaSpecificConfig: configapi.LDAPSchemaSpecificConfig{
342
+			RFC2307Config: &configapi.RFC2307Config{
343
+				GroupQuery: configapi.LDAPQuery{
344
+					BaseDN:         GroupBaseDN,
345
+					Scope:          LDAPScopeWholeSubtree,
346
+					DerefAliases:   LDAPNeverDerefAliases,
347
+					TimeLimit:      LDAPQueryTimeout,
348
+					Filter:         GroupFilter,
349
+					QueryAttribute: GroupQueryAttribute,
350
+				},
351
+				GroupNameAttributes:       []string{GroupNameAttribute1, GroupNameAttribute2},
352
+				GroupMembershipAttributes: []string{GroupMembershipAttribute},
353
+				UserQuery: configapi.LDAPQuery{
354
+					BaseDN:         UserBaseDN,
355
+					Scope:          LDAPScopeWholeSubtree,
356
+					DerefAliases:   LDAPNeverDerefAliases,
357
+					TimeLimit:      LDAPQueryTimeout,
358
+					Filter:         UserFilter,
359
+					QueryAttribute: UserQueryAttribute,
360
+				},
361
+				UserNameAttributes: []string{UserNameAttribute1, UserNameAttribute2, UserNameAttribute3},
362
+			},
363
+		},
364
+	}
365
+}
366
+
367
+// createBareGroup will create a new Group with only the data necessary for it to be accepted as having been previously
368
+// synced from LDAP to allow us to add it to etcd and simulate a previous sync job
369
+func createBareGroup(in userapi.Group) userapi.Group {
370
+	return userapi.Group{
371
+		ObjectMeta: kapi.ObjectMeta{
372
+			Name:      in.Name,
373
+			Namespace: in.Namespace,
374
+			Annotations: map[string]string{
375
+				ldaputil.LDAPUIDAnnotation: in.Annotations[ldaputil.LDAPUIDAnnotation],
376
+				ldaputil.LDAPURLAnnotation: in.Annotations[ldaputil.LDAPURLAnnotation],
377
+			},
378
+		},
379
+	}
380
+}
381
+
382
+// checkSetEquality treats the incoming slices as sets and returns true if the sets are equal
383
+func checkSetEquality(have, want []userapi.Group) (bool, error) {
384
+	// remove sync timestamp because it is not predictable and will cause DeepEqual to fail
385
+	for _, obj := range have {
386
+		_, ok := obj.Annotations[ldaputil.LDAPSyncTimeAnnotation]
387
+		if !ok {
388
+			return false, fmt.Errorf("synced group expected to have a %s annotation, but didn't",
389
+				ldaputil.LDAPSyncTimeAnnotation)
390
+		}
391
+		delete(obj.Annotations, ldaputil.LDAPSyncTimeAnnotation)
392
+	}
393
+
394
+	if len(have) != len(want) {
395
+		return false, fmt.Errorf("expected %v groups, got %v: wanted\n\t%#v\ngot\n\t%#v", len(want), len(have), want, have)
396
+	}
397
+
398
+	// if what we want and what we have are the same size and size 0, we're done
399
+	if len(want) == 0 {
400
+		return true, nil
401
+	}
402
+
403
+	// check that all entries in have exist in want
404
+	for _, haveObj := range have {
405
+		wantWhatWeHave := false
406
+		for _, wantObj := range want {
407
+			if reflect.DeepEqual(haveObj, wantObj) {
408
+				wantWhatWeHave = true
409
+			}
410
+		}
411
+		if !wantWhatWeHave {
412
+			return false, fmt.Errorf("did not expect group record from sync job: %v", haveObj)
413
+		}
414
+	}
415
+	return true, nil
416
+}
417
+
418
+// cleanup removes all Group records from the OpenShift cluster to ready it for the next test
419
+func cleanup(client *client.Client) error {
420
+	groupList, err := client.Groups().List(labels.Everything(), fields.Everything())
421
+	if err != nil {
422
+		return err
423
+	}
424
+
425
+	for _, group := range groupList.Items {
426
+		err = client.Groups().Delete(group.Name)
427
+		if err != nil {
428
+			return err
429
+		}
430
+	}
431
+
432
+	return nil
433
+}