Skip to content
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

Merged
merged 71 commits into from
Apr 8, 2025
Merged
Changes from all commits
Commits
Show all changes
71 commits
Select commit Hold shift + click to select a range
ab36b65
Add DB schema
getvictor Mar 26, 2025
87ca24c
WIP
getvictor Mar 27, 2025
81232f6
Merge remote-tracking branch 'origin/main' into victor/27287-scim-users
getvictor Mar 27, 2025
ba7ad7c
WIP
getvictor Mar 27, 2025
9e1a99c
WIP
getvictor Mar 27, 2025
5c9a729
WIP
getvictor Mar 27, 2025
9734782
WIP
getvictor Mar 28, 2025
755f787
Fix lint
getvictor Mar 28, 2025
651bcf0
WIP -- middleware
getvictor Mar 28, 2025
52ba719
Added authentication
getvictor Mar 28, 2025
2628145
Added proper header for errors
getvictor Mar 28, 2025
7a5e54c
Insert emails as a batch.
getvictor Mar 28, 2025
f9fa556
Merge remote-tracking branch 'origin/main' into victor/27287-scim-users
getvictor Mar 31, 2025
f815015
Resolved schema conflict.
getvictor Mar 31, 2025
a9e70ae
Change scim_groups.display_name to NOT NULL since it is required.
getvictor Mar 31, 2025
a4c1ab3
Made scim_groups.display_name unique
getvictor Apr 1, 2025
bc38854
Update schema.sql
getvictor Apr 1, 2025
e79e40a
WIP
getvictor Mar 31, 2025
f3a321a
WIP
getvictor Mar 31, 2025
c81eb56
SCIM groups Datastore methods.
getvictor Mar 31, 2025
a75430e
WIP
getvictor Mar 31, 2025
5c377b9
Finished Datastore method.
getvictor Mar 31, 2025
4465760
WIP
getvictor Mar 31, 2025
5386e37
WIP
getvictor Mar 31, 2025
85ea6fa
WIP
getvictor Apr 1, 2025
bd05746
WIP
getvictor Apr 1, 2025
a25359e
Merge remote-tracking branch 'origin/main' into victor/27287-scim-groups
getvictor Apr 1, 2025
fafccb3
gomt
getvictor Apr 1, 2025
d4566e8
Check for group display name uniqueness.
getvictor Apr 1, 2025
a9154e9
Mocks
getvictor Apr 1, 2025
94503e3
Self-review
getvictor Apr 1, 2025
4ae1565
First test.
getvictor Apr 1, 2025
db84bef
WIP
getvictor Apr 2, 2025
8ee4f4b
WIP
getvictor Apr 2, 2025
9f8de6f
WIP
getvictor Apr 2, 2025
ee2941e
Merge remote-tracking branch 'origin/main' into victor/27287-scim-tests
getvictor Apr 2, 2025
6c1a4f7
Updated tests.
getvictor Apr 3, 2025
18ec673
Groups tests.
getvictor Apr 3, 2025
6b3741f
More group tests.
getvictor Apr 3, 2025
db19d42
WIP
getvictor Apr 3, 2025
ecdbc3e
Added case insensitive search.
getvictor Apr 3, 2025
1e4d655
WIP.
getvictor Apr 3, 2025
99b9f02
Added users and groups test.
getvictor Apr 3, 2025
e0e9ae8
Adding some extra tests.
getvictor Apr 3, 2025
80245fe
Done?
getvictor Apr 3, 2025
c027a96
Remove dead code.
getvictor Apr 3, 2025
2390050
Updated docs.
getvictor Apr 3, 2025
2bd19b2
Removed comments.
getvictor Apr 3, 2025
41f9081
Update contributing doc.
getvictor Apr 3, 2025
a702944
Update end user authentication to use SCIM if needed.
getvictor Apr 4, 2025
b0c7a3f
Added changes
getvictor Apr 4, 2025
81fc52e
Updated PUT endpoint to recognize unique username/displayName.
getvictor Apr 4, 2025
0953d94
WIP
getvictor Apr 4, 2025
ba9e723
WIP
getvictor Apr 4, 2025
5713caf
WIP
getvictor Apr 4, 2025
647f84e
Added updatedAt to ScimUser
getvictor Apr 4, 2025
5a6f6f2
WIP
getvictor Apr 4, 2025
0302ac2
WIP
getvictor Apr 4, 2025
340fae0
Merge remote-tracking branch 'origin/main' into victor/27284-scim-hos…
getvictor Apr 4, 2025
fec6ea2
Merge remote-tracking branch 'origin/main' into victor/27284-scim-hos…
getvictor Apr 4, 2025
05df815
Fix fragile test.
getvictor Apr 4, 2025
87ae8e4
Fixing flaky tests.
getvictor Apr 4, 2025
abcad2b
Fixing flaky tests.
getvictor Apr 4, 2025
83e14fe
Fixing flaky tests.
getvictor Apr 5, 2025
820b889
Fix host test
getvictor Apr 5, 2025
69361fd
Fix fleetctl test
getvictor Apr 5, 2025
510c8d0
Testing done?
getvictor Apr 7, 2025
9ec617e
Merge remote-tracking branch 'origin/main' into victor/27284-scim-hos…
getvictor Apr 7, 2025
9226da8
Reverted API doc changes. They have a separate PR.
getvictor Apr 7, 2025
4e8c695
Added cleanup step to turn off MDM SSO.
getvictor Apr 7, 2025
a59c382
Fixes from Martin's code review.
getvictor Apr 7, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changes/23236-scim
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.
7 changes: 6 additions & 1 deletion cmd/fleetctl/testing_utils.go
Original file line number Diff line number Diff line change
@@ -157,7 +157,12 @@ func runServerWithMockedDS(t *testing.T, opts ...*service.TestServerOpts) (*http
ds.ValidateEmbeddedSecretsFunc = func(ctx context.Context, documents []string) error {
return nil
}

ds.ScimUserByHostIDFunc = func(ctx context.Context, hostID uint) (*fleet.ScimUser, error) {
return nil, nil
}
ds.ListHostDeviceMappingFunc = func(ctx context.Context, id uint) ([]*fleet.HostDeviceMapping, error) {
return nil, nil
}
var cachedDS fleet.Datastore
if len(opts) > 0 && opts[0].NoCacheDatastore {
cachedDS = ds
8 changes: 6 additions & 2 deletions docs/Contributing/SCIM-integration.md
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

![Okta to Fleet provisioning](./assets/SCIM-Okta-provisioning.png)

### 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]",
255 changes: 239 additions & 16 deletions ee/server/integrationtest/scim/scim_test.go
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)
Copy link
Member

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.

Copy link
Member Author

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.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Gotcha

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)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You should use t.Cleanup(...) immediately after those users are created to make those calls to ensure they are always done on test end.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We already do defer mysql.TruncateTables to clean up the DB. These are here just to make sure delete statements still work. They may be overkill, but I figured they couldn't hurt. I'll change the comment.

Copy link
Member

Choose a reason for hiding this comment

The 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)
}
Loading