-
Notifications
You must be signed in to change notification settings - Fork 541
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
SCIM + host integration #27880
SCIM + host integration #27880
Changes from all commits
ab36b65
87ca24c
81232f6
ba7ad7c
9e1a99c
5c9a729
9734782
755f787
651bcf0
52ba719
2628145
7a5e54c
f9fa556
f815015
a9e70ae
a4c1ab3
bc38854
e79e40a
f3a321a
c81eb56
a75430e
5c377b9
4465760
5386e37
85ea6fa
bd05746
a25359e
fafccb3
d4566e8
a9154e9
94503e3
4ae1565
db84bef
8ee4f4b
9f8de6f
ee2941e
6c1a4f7
18ec673
6b3741f
db19d42
ecdbc3e
1e4d655
99b9f02
e0e9ae8
80245fe
c027a96
2390050
2bd19b2
41f9081
a702944
b0c7a3f
81fc52e
0953d94
ba9e723
5713caf
647f84e
5a6f6f2
0302ac2
340fae0
fec6ea2
05df815
87ae8e4
abcad2b
83e14fe
820b889
69361fd
510c8d0
9ec617e
9226da8
4e8c695
a59c382
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
Added SCIM integration, which allows IdP email, full name, and groups to be visible in host vitals. SCIM data is also used for getting the end user's full name during end user authentication of macOS setup flow, if needed. Currently, only Okta IdP is supported. |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -15,13 +15,17 @@ Sample provisioning settings that work. Capabilities can be disabled and attribu | |
|
||
 | ||
|
||
### Testing Okta integration | ||
From our testing with Okta, we see the following behavior that is worth noting: | ||
- Okta does not use PATCH endpoint | ||
- Okta does not DELETE users; if a new user needs to be created with the same username as a "deleted" user, then it overwrites the old user | ||
|
||
### Automated test for Okta integration | ||
|
||
First, create at least one SCIM user: | ||
|
||
``` | ||
POST https://localhost:8080/api/latest/fleet/scim/Users | ||
|
||
Authorization: Bearer <API key> | ||
{ | ||
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"], | ||
"userName": "[email protected]", | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -26,21 +26,21 @@ func TestSCIM(t *testing.T) { | |
{"Groups", testGroupsBasicCRUD}, | ||
{"CreateUser", testCreateUser}, | ||
{"CreateGroup", testCreateGroup}, | ||
{"UpdateUser", testUpdateUser}, | ||
{"UpdateGroup", testUpdateGroup}, | ||
{"PatchUserFailure", testPatchUserFailure}, | ||
{"UsersPagination", testUsersPagination}, | ||
{"GroupsPagination", testGroupsPagination}, | ||
{"UsersAndGroups", testUsersAndGroups}, | ||
} | ||
for _, c := range cases { | ||
t.Run(c.name, func(t *testing.T) { | ||
defer mysql.TruncateTables(t, s.DS, tablesToTruncate...) | ||
defer mysql.TruncateTables(t, s.DS, []string{"host_scim_user", "scim_users", "scim_groups"}...) | ||
c.fn(t, s) | ||
}) | ||
} | ||
} | ||
|
||
var tablesToTruncate = []string{"host_scim_user", "scim_users", "scim_groups"} | ||
|
||
func testAuth(t *testing.T, s *Suite) { | ||
t.Cleanup(func() { | ||
s.Token = s.GetTestAdminToken(t) | ||
|
@@ -480,7 +480,7 @@ func testGroupsBasicCRUD(t *testing.T, s *Suite) { | |
assert.EqualValues(t, deleteAgainResp["schemas"], []interface{}{"urn:ietf:params:scim:api:messages:2.0:Error"}) | ||
assert.Contains(t, deleteAgainResp["detail"], "not found") | ||
|
||
// Clean up the user we created | ||
// Delete the user we created | ||
s.Do(t, "DELETE", scimPath("/Users/"+userID), nil, http.StatusNoContent) | ||
} | ||
|
||
|
@@ -644,13 +644,12 @@ func testCreateGroup(t *testing.T, s *Suite) { | |
// Verify error response | ||
assert.EqualValues(t, errorResp["schemas"], []interface{}{"urn:ietf:params:scim:api:messages:2.0:Error"}) | ||
|
||
// Clean up | ||
// Delete the groups | ||
// Delete the groups we created | ||
s.Do(t, "DELETE", scimPath("/Groups/"+emptyGroupID), nil, http.StatusNoContent) | ||
s.Do(t, "DELETE", scimPath("/Groups/"+manyMembersGroupID), nil, http.StatusNoContent) | ||
s.Do(t, "DELETE", scimPath("/Groups/"+externalIDGroupID), nil, http.StatusNoContent) | ||
|
||
// Delete the users | ||
// Delete the users we created | ||
for _, userID := range userIDs { | ||
s.Do(t, "DELETE", scimPath("/Users/"+userID), nil, http.StatusNoContent) | ||
} | ||
|
@@ -1043,7 +1042,220 @@ func testPatchUserFailure(t *testing.T, s *Suite) { | |
assert.EqualValues(t, errorResp5["schemas"], []interface{}{"urn:ietf:params:scim:api:messages:2.0:Error"}) | ||
assert.Contains(t, errorResp5["detail"], "A required value was missing") | ||
|
||
// Clean up the created user | ||
// Delete the created user | ||
s.Do(t, "DELETE", scimPath("/Users/"+userID), nil, http.StatusNoContent) | ||
} | ||
|
||
func testUpdateUser(t *testing.T, s *Suite) { | ||
// Create first user | ||
firstUserPayload := map[string]interface{}{ | ||
"schemas": []string{"urn:ietf:params:scim:schemas:core:2.0:User"}, | ||
"userName": "[email protected]", | ||
"name": map[string]interface{}{ | ||
"givenName": "First", | ||
"familyName": "User", | ||
}, | ||
"emails": []map[string]interface{}{ | ||
{ | ||
"value": "[email protected]", | ||
"type": "work", | ||
"primary": true, | ||
}, | ||
}, | ||
"active": true, | ||
} | ||
|
||
var firstUserResp map[string]interface{} | ||
s.DoJSON(t, "POST", scimPath("/Users"), firstUserPayload, http.StatusCreated, &firstUserResp) | ||
firstUserID := firstUserResp["id"].(string) | ||
assert.NotEmpty(t, firstUserID) | ||
|
||
// Create second user | ||
secondUserPayload := map[string]interface{}{ | ||
"schemas": []string{"urn:ietf:params:scim:schemas:core:2.0:User"}, | ||
"userName": "[email protected]", | ||
"name": map[string]interface{}{ | ||
"givenName": "Second", | ||
"familyName": "User", | ||
}, | ||
"emails": []map[string]interface{}{ | ||
{ | ||
"value": "[email protected]", | ||
"type": "work", | ||
"primary": true, | ||
}, | ||
}, | ||
"active": true, | ||
} | ||
|
||
var secondUserResp map[string]interface{} | ||
s.DoJSON(t, "POST", scimPath("/Users"), secondUserPayload, http.StatusCreated, &secondUserResp) | ||
secondUserID := secondUserResp["id"].(string) | ||
assert.NotEmpty(t, secondUserID) | ||
|
||
// Test 1: Try to update first user's userName to be exactly the same as second user's userName | ||
updatePayload := map[string]interface{}{ | ||
"schemas": []string{"urn:ietf:params:scim:schemas:core:2.0:User"}, | ||
"userName": "[email protected]", // Same as second user | ||
"name": map[string]interface{}{ | ||
"givenName": "First", | ||
"familyName": "User", | ||
}, | ||
"emails": []map[string]interface{}{ | ||
{ | ||
"value": "[email protected]", | ||
"type": "work", | ||
"primary": true, | ||
}, | ||
}, | ||
"active": true, | ||
} | ||
|
||
var errorResp1 map[string]interface{} | ||
s.DoJSON(t, "PUT", scimPath("/Users/"+firstUserID), updatePayload, http.StatusConflict, &errorResp1) | ||
|
||
// Verify error response | ||
assert.EqualValues(t, errorResp1["schemas"], []interface{}{"urn:ietf:params:scim:api:messages:2.0:Error"}) | ||
assert.Contains(t, errorResp1["detail"], "One or more of the attribute values are already in use or are reserved") | ||
|
||
// Test 2: Try to update first user's userName to be a case-randomized version of second user's userName | ||
updatePayload["userName"] = "[email protected]" // Case-randomized version of second user's userName | ||
|
||
var errorResp2 map[string]interface{} | ||
s.DoJSON(t, "PUT", scimPath("/Users/"+firstUserID), updatePayload, http.StatusConflict, &errorResp2) | ||
|
||
// Verify error response | ||
assert.EqualValues(t, errorResp2["schemas"], []interface{}{"urn:ietf:params:scim:api:messages:2.0:Error"}) | ||
assert.Contains(t, errorResp2["detail"], "One or more of the attribute values are already in use or are reserved") | ||
|
||
// Test 3: Try to update first user's userName to be exactly the same as its current userName (should succeed) | ||
updatePayload["userName"] = "[email protected]" // Same as current userName | ||
|
||
var updateResp map[string]interface{} | ||
s.DoJSON(t, "PUT", scimPath("/Users/"+firstUserID), updatePayload, http.StatusOK, &updateResp) | ||
|
||
// Verify the update was successful | ||
assert.Equal(t, "[email protected]", updateResp["userName"]) | ||
|
||
// Test 4: Try to update first user's userName to be a case-randomized version of its current userName (should succeed) | ||
updatePayload["userName"] = "[email protected]" // Case-randomized version of current userName | ||
|
||
var updateResp2 map[string]interface{} | ||
s.DoJSON(t, "PUT", scimPath("/Users/"+firstUserID), updatePayload, http.StatusOK, &updateResp2) | ||
|
||
// Verify the update was successful. | ||
assert.Equal(t, "[email protected]", updateResp2["userName"]) | ||
|
||
// Delete the users we created. | ||
s.Do(t, "DELETE", scimPath("/Users/"+firstUserID), nil, http.StatusNoContent) | ||
s.Do(t, "DELETE", scimPath("/Users/"+secondUserID), nil, http.StatusNoContent) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. You should use There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We already do There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Ah ok, yeah changing the comment is a good idea if that's part of the test, it was misleading for me. |
||
} | ||
|
||
func testUpdateGroup(t *testing.T, s *Suite) { | ||
// Create a test user to be added as a member of the groups | ||
createUserPayload := map[string]interface{}{ | ||
"schemas": []string{"urn:ietf:params:scim:schemas:core:2.0:User"}, | ||
"userName": "[email protected]", | ||
"name": map[string]interface{}{ | ||
"givenName": "Group", | ||
"familyName": "UpdateTest", | ||
}, | ||
"emails": []map[string]interface{}{ | ||
{ | ||
"value": "[email protected]", | ||
"type": "work", | ||
"primary": true, | ||
}, | ||
}, | ||
"active": true, | ||
} | ||
|
||
var createUserResp map[string]interface{} | ||
s.DoJSON(t, "POST", scimPath("/Users"), createUserPayload, http.StatusCreated, &createUserResp) | ||
userID := createUserResp["id"].(string) | ||
assert.NotEmpty(t, userID) | ||
|
||
// Create first group | ||
firstGroupPayload := map[string]interface{}{ | ||
"schemas": []string{"urn:ietf:params:scim:schemas:core:2.0:Group"}, | ||
"displayName": "First Test Group", | ||
"members": []map[string]interface{}{ | ||
{ | ||
"value": userID, | ||
}, | ||
}, | ||
} | ||
|
||
var firstGroupResp map[string]interface{} | ||
s.DoJSON(t, "POST", scimPath("/Groups"), firstGroupPayload, http.StatusCreated, &firstGroupResp) | ||
firstGroupID := firstGroupResp["id"].(string) | ||
assert.NotEmpty(t, firstGroupID) | ||
|
||
// Create second group | ||
secondGroupPayload := map[string]interface{}{ | ||
"schemas": []string{"urn:ietf:params:scim:schemas:core:2.0:Group"}, | ||
"displayName": "Second Test Group", | ||
"members": []map[string]interface{}{ | ||
{ | ||
"value": userID, | ||
}, | ||
}, | ||
} | ||
|
||
var secondGroupResp map[string]interface{} | ||
s.DoJSON(t, "POST", scimPath("/Groups"), secondGroupPayload, http.StatusCreated, &secondGroupResp) | ||
secondGroupID := secondGroupResp["id"].(string) | ||
assert.NotEmpty(t, secondGroupID) | ||
|
||
// Test 1: Try to update first group's displayName to be exactly the same as second group's displayName | ||
updatePayload := map[string]interface{}{ | ||
"schemas": []string{"urn:ietf:params:scim:schemas:core:2.0:Group"}, | ||
"displayName": "Second Test Group", // Same as second group | ||
"members": []map[string]interface{}{ | ||
{ | ||
"value": userID, | ||
}, | ||
}, | ||
} | ||
|
||
var errorResp1 map[string]interface{} | ||
s.DoJSON(t, "PUT", scimPath("/Groups/"+firstGroupID), updatePayload, http.StatusConflict, &errorResp1) | ||
|
||
// Verify error response | ||
assert.EqualValues(t, errorResp1["schemas"], []interface{}{"urn:ietf:params:scim:api:messages:2.0:Error"}) | ||
assert.Contains(t, errorResp1["detail"], "One or more of the attribute values are already in use or are reserved") | ||
|
||
// Test 2: Try to update first group's displayName to be a case-randomized version of second group's displayName | ||
updatePayload["displayName"] = "SeCoNd TeSt GrOuP" // Case-randomized version of second group's displayName | ||
|
||
var errorResp2 map[string]interface{} | ||
s.DoJSON(t, "PUT", scimPath("/Groups/"+firstGroupID), updatePayload, http.StatusConflict, &errorResp2) | ||
|
||
// Verify error response | ||
assert.EqualValues(t, errorResp2["schemas"], []interface{}{"urn:ietf:params:scim:api:messages:2.0:Error"}) | ||
assert.Contains(t, errorResp2["detail"], "One or more of the attribute values are already in use or are reserved") | ||
|
||
// Test 3: Try to update first group's displayName to be exactly the same as its current displayName (should succeed) | ||
updatePayload["displayName"] = "First Test Group" // Same as current displayName | ||
|
||
var updateResp map[string]interface{} | ||
s.DoJSON(t, "PUT", scimPath("/Groups/"+firstGroupID), updatePayload, http.StatusOK, &updateResp) | ||
|
||
// Verify the update was successful | ||
assert.Equal(t, "First Test Group", updateResp["displayName"]) | ||
|
||
// Test 4: Try to update first group's displayName to be a case-randomized version of its current displayName (should succeed) | ||
updatePayload["displayName"] = "FiRsT TeSt GrOuP" // Case-randomized version of current displayName | ||
|
||
var updateResp2 map[string]interface{} | ||
s.DoJSON(t, "PUT", scimPath("/Groups/"+firstGroupID), updatePayload, http.StatusOK, &updateResp2) | ||
|
||
// Verify the update was successful but the displayName is normalized | ||
assert.Equal(t, "FiRsT TeSt GrOuP", updateResp2["displayName"]) | ||
|
||
// Delete the users and groups we created. | ||
s.Do(t, "DELETE", scimPath("/Groups/"+firstGroupID), nil, http.StatusNoContent) | ||
s.Do(t, "DELETE", scimPath("/Groups/"+secondGroupID), nil, http.StatusNoContent) | ||
s.Do(t, "DELETE", scimPath("/Users/"+userID), nil, http.StatusNoContent) | ||
} | ||
|
||
|
@@ -1200,7 +1412,7 @@ func testUsersPagination(t *testing.T, s *Suite) { | |
assert.True(t, ok, "Resources should be an array") | ||
assert.Equal(t, 10, len(allResources), "All users page should have 10 users") | ||
|
||
// Clean up all created users | ||
// Delete all created users | ||
for _, userID := range userIDs { | ||
s.Do(t, "DELETE", scimPath("/Users/"+userID), nil, http.StatusNoContent) | ||
} | ||
|
@@ -1414,12 +1626,12 @@ func testGroupsPagination(t *testing.T, s *Suite) { | |
} | ||
} | ||
|
||
// Clean up all created groups | ||
// Delete all created groups | ||
for _, groupID := range groupIDs { | ||
s.Do(t, "DELETE", scimPath("/Groups/"+groupID), nil, http.StatusNoContent) | ||
} | ||
|
||
// Clean up the user | ||
// Delete the user we created | ||
s.Do(t, "DELETE", scimPath("/Users/"+userID), nil, http.StatusNoContent) | ||
} | ||
|
||
|
@@ -1507,6 +1719,7 @@ func testUsersAndGroups(t *testing.T, s *Suite) { | |
assert.True(t, ok, "Group should be an object") | ||
assert.Equal(t, group1ID, group["value"], "User 1 should be in Group 1") | ||
assert.Equal(t, "Groups/"+group1ID, group["$ref"], "Group $ref should be correct") | ||
assert.Equal(t, "Test Group 1", group["display"], "Group display name should be correct") | ||
} | ||
|
||
// Test 2: Verify that User 2 is in both Group 1 and Group 2 | ||
|
@@ -1520,13 +1733,18 @@ func testUsersAndGroups(t *testing.T, s *Suite) { | |
|
||
// Verify the groups include both Group 1 and Group 2 | ||
groupValues := make([]string, 0, 2) | ||
groupDisplays := make(map[string]string) | ||
for _, g := range user2Groups { | ||
group, ok := g.(map[string]interface{}) | ||
assert.True(t, ok, "Group should be an object") | ||
groupValues = append(groupValues, group["value"].(string)) | ||
groupID := group["value"].(string) | ||
groupValues = append(groupValues, groupID) | ||
groupDisplays[groupID] = group["display"].(string) | ||
} | ||
assert.Contains(t, groupValues, group1ID, "User 2 should be in Group 1") | ||
assert.Contains(t, groupValues, group2ID, "User 2 should be in Group 2") | ||
assert.Equal(t, "Test Group 1", groupDisplays[group1ID], "Group 1 display name should be correct") | ||
assert.Equal(t, "Test Group 2", groupDisplays[group2ID], "Group 2 display name should be correct") | ||
|
||
// Test 3: Verify that User 3 is in Group 2 only | ||
var user3Resp map[string]interface{} | ||
|
@@ -1543,6 +1761,7 @@ func testUsersAndGroups(t *testing.T, s *Suite) { | |
assert.True(t, ok, "Group should be an object") | ||
assert.Equal(t, group2ID, group["value"], "User 3 should be in Group 2") | ||
assert.Equal(t, "Groups/"+group2ID, group["$ref"], "Group $ref should be correct") | ||
assert.Equal(t, "Test Group 2", group["display"], "Group display name should be correct") | ||
} | ||
|
||
// Test 4: Update Group 1 to remove User 1 and add User 3 | ||
|
@@ -1581,20 +1800,24 @@ func testUsersAndGroups(t *testing.T, s *Suite) { | |
|
||
// Verify the groups include both Group 1 and Group 2 | ||
groupValues = make([]string, 0, 2) | ||
groupDisplays = make(map[string]string) | ||
for _, g := range user3Groups { | ||
group, ok := g.(map[string]interface{}) | ||
assert.True(t, ok, "Group should be an object") | ||
groupValues = append(groupValues, group["value"].(string)) | ||
groupID := group["value"].(string) | ||
groupValues = append(groupValues, groupID) | ||
groupDisplays[groupID] = group["display"].(string) | ||
} | ||
assert.Contains(t, groupValues, group1ID, "User 3 should be in Group 1") | ||
assert.Contains(t, groupValues, group2ID, "User 3 should be in Group 2") | ||
assert.Equal(t, "Test Group 1", groupDisplays[group1ID], "Group 1 display name should be correct") | ||
assert.Equal(t, "Test Group 2", groupDisplays[group2ID], "Group 2 display name should be correct") | ||
|
||
// Clean up | ||
// Delete the groups | ||
// Delete the groups we created | ||
s.Do(t, "DELETE", scimPath("/Groups/"+group1ID), nil, http.StatusNoContent) | ||
s.Do(t, "DELETE", scimPath("/Groups/"+group2ID), nil, http.StatusNoContent) | ||
|
||
// Delete the users | ||
// Delete the users we created | ||
for _, userID := range userIDs { | ||
s.Do(t, "DELETE", scimPath("/Users/"+userID), nil, http.StatusNoContent) | ||
} | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nit: I think using sub-tests would structure the test a bit better than numbered comments.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, some of these are independent and can be done in a stand-alone sub-test. But others, like this
Test 4
above actually change the DB, and don't make sense in a sub-test.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Gotcha