-
Notifications
You must be signed in to change notification settings - Fork 11
/
client_wrapper.go
258 lines (221 loc) · 10 KB
/
client_wrapper.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
package oktaapi
import (
"context"
"encoding/json"
"errors"
"fmt"
"regexp"
"strings"
"github.com/okta/okta-sdk-golang/v2/okta"
"github.com/okta/okta-sdk-golang/v2/okta/query"
"go.uber.org/zap"
"github.com/cmsgov/easi-app/pkg/appcontext"
"github.com/cmsgov/easi-app/pkg/models"
)
const maxEUAIDLength = 4
// ClientWrapper is a wrapper around github.com/okta/okta-sdk-golang/v2/okta Client type.
// The purpose of this package is to act as a simplified client for the Okta API. The methods expected to be used for that client are
// defined in the Client interface in pkg/usersearch.
type ClientWrapper struct {
oktaClient *okta.Client
}
// NewClient creates a Client
func NewClient(url string, token string) (*ClientWrapper, error) {
// TODO Do we need the "Context" response from okta.NewClient??
_, oktaClient, oktaClientErr := okta.NewClient(context.TODO(), okta.WithOrgUrl(url), okta.WithToken(token))
if oktaClientErr != nil {
return nil, oktaClientErr
}
return &ClientWrapper{ //TODO: implement the next function
oktaClient: oktaClient,
}, nil
}
// oktaUserResponse is used to marshal the JSON response from Okta into a struct
type oktaUserResponse struct {
FirstName string `json:"firstName"`
LastName string `json:"lastName"`
DisplayName string `json:"displayName"`
Email string `json:"email"`
Login string `json:"login"`
SourceType string `json:"SourceType"`
}
// Converts the generic JSON type of okta.UserProfile into oktaUserResponse
// This also will auto-capitalize the EUA
func (cw *ClientWrapper) parseOktaProfileResponse(ctx context.Context, profile *okta.UserProfile) (*oktaUserResponse, error) {
logger := appcontext.ZLogger(ctx)
// Create an oktaUserProfile to return
parsedProfile := &oktaUserResponse{}
// Marshal the profile into a string so we can later unmarshal it into a struct
responseString, err := json.Marshal(profile)
if err != nil {
logger.Error("error marshalling okta response", zap.Error(err))
return nil, err
}
// Unmarshal the string into the oktaUserProfile type
err = json.Unmarshal(responseString, parsedProfile)
if err != nil {
logger.Error("error unmarshalling okta response", zap.Error(err))
return nil, err
}
// This only works because we know that ALL logins are EUAs. If we ever support non-EUA login, we need to conditional this
parsedProfile.Login = strings.ToUpper(parsedProfile.Login)
return parsedProfile, nil
}
func (o *oktaUserResponse) toUserInfo() *models.UserInfo {
return &models.UserInfo{
FirstName: o.FirstName,
LastName: o.LastName,
DisplayName: o.DisplayName,
Email: models.NewEmailAddress(o.Email),
Username: o.Login,
}
}
// FetchUserInfos fetches users from Okta by EUA ID
func (cw *ClientWrapper) FetchUserInfos(ctx context.Context, usernames []string) ([]*models.UserInfo, error) {
logger := appcontext.ZLogger(ctx)
users := []*models.UserInfo{}
if len(usernames) == 0 {
return users, nil
}
var euaSearches []string
for _, username := range usernames {
euaSearches = append(euaSearches, fmt.Sprintf(`profile.login eq "%v"`, username))
}
euaSearch := strings.Join(euaSearches, " or ")
searchString := fmt.Sprintf(`(%v)`, euaSearch)
search := query.NewQueryParams(query.WithSearch(searchString))
searchedUsers, _, err := cw.oktaClient.User.ListUsers(ctx, search)
if err != nil {
// If it's also not a context cancellation, log and return the error
if !errors.Is(err, context.Canceled) {
logger.Error("Error searching Okta users", zap.Error(err), zap.String("usernames", strings.Join(usernames, ", ")))
return nil, err
}
// err is a context cancellation error, log and return early
logger.Warn("Context cancelled while searching Okta users", zap.Error(err))
return nil, nil
}
// API call was a success, but no users were found.
// We consider this an error, since we're expecting to find users
// since this isn't a "search", but a lookup by EUA ID
if len(searchedUsers) == 0 {
appcontext.ZLogger(ctx).Error("no users found when calling FetchUserInfos", zap.String("usernames", strings.Join(usernames, ",")))
return users, fmt.Errorf("no users found")
}
for _, user := range searchedUsers {
profile, err := cw.parseOktaProfileResponse(ctx, user.Profile)
if err != nil {
return nil, err
}
users = append(users, profile.toUserInfo())
}
return users, nil
}
// FetchUserInfo fetches a single user from Okta by EUA ID
func (cw *ClientWrapper) FetchUserInfo(ctx context.Context, username string) (*models.UserInfo, error) {
logger := appcontext.ZLogger(ctx)
user, _, err := cw.oktaClient.User.GetUser(ctx, username)
if err != nil {
// Only log the error if it's not a context cancellation, we don't really care about these (but still pass it up the call stack)
if !errors.Is(err, context.Canceled) {
logger.Error("Error fetching Okta user", zap.Error(err), zap.String("username", username))
}
return nil, err
}
profile, err := cw.parseOktaProfileResponse(ctx, user.Profile)
if err != nil {
return nil, err
}
return profile.toUserInfo(), nil
}
// FetchUserInfoByCommonName fetches a single user from Okta by commonName
// It will error if no users are found, and will also error if there are more than one result for that user
// It is possible that users would share a name, but other functions must be used for to return the array.
func (cw *ClientWrapper) FetchUserInfoByCommonName(ctx context.Context, commonName string) (*models.UserInfo, error) {
users, err := cw.SearchCommonNameContainsExhaustive(ctx, commonName)
logger := appcontext.ZLogger(ctx)
if err != nil {
// Only log the error if it's not a context cancellation, we don't really care about these (but still pass it up the call stack)
if !errors.Is(err, context.Canceled) {
logger.Error("Error fetching Okta user", zap.Error(err), zap.String("commonName", commonName))
}
return nil, err
}
userNum := len(users)
if userNum < 1 {
return nil, fmt.Errorf("unable to find user by common name: %s", commonName)
} else if userNum > 1 {
return nil, fmt.Errorf("multiple users found by common name: %s", commonName)
}
// There is only one user
return users[0], nil
}
const euaSourceType = "EUA"
const euaADSourceType = "EUA-AD"
// SearchCommonNameContains searches for a user by their First/Last name in Okta
func (cw *ClientWrapper) SearchCommonNameContains(ctx context.Context, searchTerm string) ([]*models.UserInfo, error) {
logger := appcontext.ZLogger(ctx)
// Sanitize searchTerm for \ and ". These characters cause Okta to error.
filterRegex := regexp.MustCompile(`[\\"]`)
searchTerm = filterRegex.ReplaceAllString(searchTerm, "")
// profile.SourceType can be EUA, EUA-AD, or cmsidm
// the first 2 represent EUA users, the latter represents users created directly in IDM
// status eq "ACTIVE" or status eq "STAGED" ensures we only get users who have EUAs (Staged means they just haven't logged in yet)
// Okta API only supports matching by "starts with" (sw) or strict equality and not wildcards or "ends with"
isFromEUA := fmt.Sprintf(`(profile.SourceType eq "%v" or profile.SourceType eq "%v")`, euaSourceType, euaADSourceType)
isActiveOrStaged := `(status eq "ACTIVE" or status eq "STAGED")`
nameSearch := fmt.Sprintf(`(profile.firstName sw "%v" or profile.lastName sw "%v" or profile.displayName sw "%v")`, searchTerm, searchTerm, searchTerm)
searchString := fmt.Sprintf(`%v and %v and %v`, isFromEUA, isActiveOrStaged, nameSearch)
search := query.NewQueryParams(query.WithSearch(searchString))
searchedUsers, _, err := cw.oktaClient.User.ListUsers(ctx, search)
if err != nil && !errors.Is(err, context.Canceled) {
logger.Error("Error searching Okta users", zap.Error(err), zap.String("searchTerm", searchTerm))
return nil, err
}
users := []*models.UserInfo{}
for _, user := range searchedUsers {
profile, err := cw.parseOktaProfileResponse(ctx, user.Profile)
if err != nil {
return nil, err
}
// If we find EUA users that have logins longer than 4 characters, they're a test user (don't add them to the array)
if (profile.SourceType == euaSourceType || profile.SourceType == euaADSourceType) && len(profile.Login) > maxEUAIDLength {
continue
}
users = append(users, profile.toUserInfo())
}
return users, nil
}
// SearchCommonNameContainsExhaustive searches for a user by their First/Last name in Okta.
// It doesn't validate if a user is currently active, which allows us to search for users no longer at CMS
func (cw *ClientWrapper) SearchCommonNameContainsExhaustive(ctx context.Context, searchTerm string) ([]*models.UserInfo, error) {
logger := appcontext.ZLogger(ctx)
// Sanitize searchTerm for \ and ". These characters cause Okta to error.
filterRegex := regexp.MustCompile(`[\\"]`)
searchTerm = filterRegex.ReplaceAllString(searchTerm, "")
// profile.SourceType can be EUA, EUA-AD, or cmsidm
// the first 2 represent EUA users, the latter represents users created directly in IDM
// Okta API only supports matching by "starts with" (sw) or strict equality and not wildcards or "ends with"
isFromEUA := fmt.Sprintf(`(profile.SourceType eq "%v" or profile.SourceType eq "%v")`, euaSourceType, euaADSourceType)
nameSearch := fmt.Sprintf(`(profile.firstName sw "%v" or profile.lastName sw "%v" or profile.displayName sw "%v")`, searchTerm, searchTerm, searchTerm)
searchString := fmt.Sprintf(`%v and %v`, isFromEUA, nameSearch)
search := query.NewQueryParams(query.WithSearch(searchString))
searchedUsers, _, err := cw.oktaClient.User.ListUsers(ctx, search)
if err != nil && !errors.Is(err, context.Canceled) {
logger.Error("Error searching Okta users", zap.Error(err), zap.String("searchTerm", searchTerm))
return nil, err
}
users := []*models.UserInfo{}
for _, user := range searchedUsers {
profile, err := cw.parseOktaProfileResponse(ctx, user.Profile)
if err != nil {
return nil, err
}
// If we find EUA users that have logins longer than 4 characters, they're a test user (don't add them to the array)
if (profile.SourceType == euaSourceType || profile.SourceType == euaADSourceType) && len(profile.Login) > maxEUAIDLength {
continue
}
users = append(users, profile.toUserInfo())
}
return users, nil
}