Added scim/details endpoint (#28007)

For #27281

This PR adds `/api/{version}/fleet/scim/details` endpoint, along with
some frontend fixes.

# Checklist for submitter
- [x] If database migrations are included, checked table schema to
confirm autoupdate
- For database migrations:
- [x] Checked schema for all modified table for columns that will
auto-update timestamps during migration.
- [x] Confirmed that updating the timestamps is acceptable, and will not
cause unwanted side effects.
- [x] Ensured the correct collation is explicitly set for character
columns (`COLLATE utf8mb4_unicode_ci`).
- [x] Added/updated automated tests
- [x] A detailed QA plan exists on the associated ticket (if it isn't
there, work with the product group's QA engineer to add it)
- [x] Manual QA for all new/changed functionality
This commit is contained in:
Victor Lyuboslavsky
2025-04-10 14:08:45 -05:00
committed by GitHub
parent bce82909a2
commit df71307eeb
29 changed files with 521 additions and 66 deletions
+38 -1
View File
@@ -6,8 +6,10 @@ import (
"testing"
"time"
"github.com/fleetdm/fleet/v4/server/authz"
"github.com/fleetdm/fleet/v4/server/datastore/mysql"
"github.com/fleetdm/fleet/v4/server/service"
"github.com/fleetdm/fleet/v4/server/service/contract"
"github.com/fleetdm/fleet/v4/server/test"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -35,7 +37,8 @@ func TestSCIM(t *testing.T) {
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
defer mysql.TruncateTables(t, s.DS, []string{"host_scim_user", "scim_users", "scim_groups"}...)
defer mysql.TruncateTables(t, s.DS, []string{"host_scim_user", "scim_users", "scim_user_emails", "scim_groups",
"scim_user_group", "scim_last_request"}...)
c.fn(t, s)
})
}
@@ -52,6 +55,13 @@ func testAuth(t *testing.T, s *Suite) {
s.DoJSON(t, "GET", scimPath("/Schemas"), nil, http.StatusUnauthorized, &resp)
assert.Contains(t, resp["detail"], "Authentication")
assert.EqualValues(t, resp["schemas"], []interface{}{"urn:ietf:params:scim:api:messages:2.0:Error"})
scimDetails := contract.ScimDetailsResponse{}
s.DoJSON(t, "GET", scimPath("/details"), nil, http.StatusUnauthorized, &scimDetails)
// Make sure unauthenticated response wasn't saved as the last SCIM request
s.Token = s.GetTestToken(t, service.TestMaintainerUserEmail, test.GoodPassword)
scimDetails = contract.ScimDetailsResponse{}
s.DoJSON(t, "GET", scimPath("/details"), nil, http.StatusOK, &scimDetails)
assert.Nil(t, scimDetails.LastRequest, "last_request should NOT be present for unauthenticated requests")
// Unauthorized
resp = nil
@@ -59,6 +69,15 @@ func testAuth(t *testing.T, s *Suite) {
s.DoJSON(t, "GET", scimPath("/Schemas"), nil, http.StatusForbidden, &resp)
assert.Contains(t, resp["detail"], "forbidden")
assert.EqualValues(t, resp["schemas"], []interface{}{"urn:ietf:params:scim:api:messages:2.0:Error"})
s.DoJSON(t, "GET", scimPath("/details"), nil, http.StatusForbidden, &scimDetails)
// Make sure unauthorized response WAS saved as the last SCIM request
s.Token = s.GetTestToken(t, service.TestMaintainerUserEmail, test.GoodPassword)
scimDetails = contract.ScimDetailsResponse{}
s.DoJSON(t, "GET", scimPath("/details"), nil, http.StatusOK, &scimDetails)
require.NotNil(t, scimDetails.LastRequest)
assert.Equal(t, "error", scimDetails.LastRequest.Status)
assert.NotZero(t, scimDetails.LastRequest.RequestedAt)
assert.Equal(t, authz.ForbiddenErrorMessage, scimDetails.LastRequest.Details)
// Authorized
resp = nil
@@ -68,9 +87,20 @@ func testAuth(t *testing.T, s *Suite) {
}
func testBaseEndpoints(t *testing.T, s *Suite) {
// Make sure SCIM details.last_request DOES NOT exist
scimDetails := contract.ScimDetailsResponse{}
s.DoJSON(t, "GET", scimPath("/details"), nil, http.StatusOK, &scimDetails)
assert.Nil(t, scimDetails.LastRequest)
// Test /Schemas endpoint
var schemasResp map[string]interface{}
s.DoJSON(t, "GET", scimPath("/Schemas"), nil, http.StatusOK, &schemasResp)
scimDetails = contract.ScimDetailsResponse{}
s.DoJSON(t, "GET", scimPath("/details"), nil, http.StatusOK, &scimDetails)
require.NotNil(t, scimDetails.LastRequest)
assert.Equal(t, "success", scimDetails.LastRequest.Status)
assert.NotZero(t, scimDetails.LastRequest.RequestedAt)
assert.Empty(t, scimDetails.LastRequest.Details)
// Verify schemas response
assert.EqualValues(t, schemasResp["schemas"], []interface{}{"urn:ietf:params:scim:api:messages:2.0:ListResponse"})
@@ -181,6 +211,13 @@ func testUsersBasicCRUD(t *testing.T, s *Suite) {
s.DoJSON(t, "GET", scimPath("/Users/99999"), nil, http.StatusNotFound, &errResp)
assert.Contains(t, errResp["detail"], "Resource 99999 not found")
assert.EqualValues(t, errResp["schemas"], []interface{}{"urn:ietf:params:scim:api:messages:2.0:Error"})
// Make sure the error is reflected in the last request
scimDetails := contract.ScimDetailsResponse{}
s.DoJSON(t, "GET", scimPath("/details"), nil, http.StatusOK, &scimDetails)
require.NotNil(t, scimDetails.LastRequest)
assert.Equal(t, "error", scimDetails.LastRequest.Status)
assert.NotZero(t, scimDetails.LastRequest.RequestedAt)
assert.Equal(t, errResp["detail"], scimDetails.LastRequest.Details)
// Test listing users
var listResp map[string]interface{}
+7 -4
View File
@@ -16,12 +16,15 @@ type Suite struct {
func SetUpSuite(t *testing.T, uniqueTestName string) *Suite {
// Note: t.Parallel() is called when MySQL datastore options are processed
ds, redisPool, fleetCfg, fleetSvc, ctx := integrationtest.SetUpMySQLAndRedisAndService(t, uniqueTestName)
license := &fleet.LicenseInfo{
Tier: fleet.TierPremium,
}
ds, redisPool, fleetCfg, fleetSvc, ctx := integrationtest.SetUpMySQLAndRedisAndService(t, uniqueTestName, &service.TestServerOpts{
License: license,
})
logger := log.NewLogfmtLogger(os.Stdout)
users, server := service.RunServerForTestsWithServiceWithDS(t, ctx, ds, fleetSvc, &service.TestServerOpts{
License: &fleet.LicenseInfo{
Tier: fleet.TierFree,
},
License: license,
FleetConfig: &fleetCfg,
Pool: redisPool,
Logger: logger,
+94 -1
View File
@@ -1,8 +1,10 @@
package scim
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"github.com/elimity-com/scim"
@@ -18,7 +20,7 @@ import (
)
const (
maxResults = 1000
maxResults = 100
)
func RegisterSCIM(
@@ -195,6 +197,7 @@ func RegisterSCIM(
handler := http.StripPrefix(prefix, server)
handler = AuthorizationMiddleware(authorizer, scimLogger, handler)
handler = auth.AuthenticatedUserMiddleware(svc, scimErrorHandler, handler)
handler = LastRequestMiddleware(ds, scimLogger, handler)
handler = log.LogResponseEndMiddleware(scimLogger, handler)
handler = auth.SetRequestsContextMiddleware(svc, handler)
return handler
@@ -207,6 +210,56 @@ func RegisterSCIM(
return nil
}
// LastRequestMiddleware saves the details of the last request to SCIM endpoints in the datastore.
// These details can be used as a debug tool by the Fleet admin to see if SCIM integration is working.
func LastRequestMiddleware(ds fleet.Datastore, logger kitlog.Logger, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
multi := newMultiResponseWriter(w)
next.ServeHTTP(multi, r)
var status, details string
switch {
case multi.statusCode == 0 || (multi.statusCode >= 200 && multi.statusCode < 300):
status = "success"
case multi.statusCode == http.StatusUnauthorized:
// We do not save unauthenticated error details; we simply log them.
level.Info(logger).Log(
"msg", "unauthenticated request",
"origin", r.Header.Get("Origin"),
"ip", r.RemoteAddr,
"method", r.Method,
"path", r.URL.Path,
"user-agent", r.UserAgent(),
"referer", r.Referer(),
)
return
case multi.statusCode >= 400:
status = "error"
// Attempt to parse the response body as a SCIM error.
var parsedScimError errors.ScimError
if err := json.Unmarshal(multi.body.Bytes(), &parsedScimError); err == nil {
details = parsedScimError.Detail
} else {
details = multi.body.String()
}
default:
status = "error"
details = fmt.Sprintf("Unhandled status code: %d", multi.statusCode)
level.Error(logger).Log("msg", "unhandled status code", "status", multi.statusCode, "body", multi.body.String())
}
if len(details) > fleet.SCIMMaxFieldLength {
details = details[:fleet.SCIMMaxFieldLength]
}
err := ds.UpdateScimLastRequest(r.Context(), &fleet.ScimLastRequest{
Status: status,
Details: details,
})
if err != nil {
level.Error(logger).Log("msg", "failed to update last scim request", "err", err)
}
})
}
func AuthorizationMiddleware(authorizer *authz.Authorizer, logger kitlog.Logger, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
err := authorizer.Authorize(r.Context(), &fleet.ScimUser{}, fleet.ActionWrite)
@@ -248,3 +301,43 @@ func (l *scimErrorLogger) Error(args ...interface{}) {
"error", fmt.Sprint(args...),
)
}
type multiResponseWriter struct {
body *bytes.Buffer
resp http.ResponseWriter
multi io.Writer
statusCode int
}
const maxBodyBufferSize = 32 * 1024 // 32K
func newMultiResponseWriter(resp http.ResponseWriter) *multiResponseWriter {
body := &bytes.Buffer{}
multi := io.MultiWriter(body, resp)
return &multiResponseWriter{
body: body,
resp: resp,
multi: multi,
}
}
// multiResponseWriter implements http.ResponseWriter
// https://golang.org/pkg/net/http/#ResponseWriter
var _ http.ResponseWriter = &multiResponseWriter{}
func (w *multiResponseWriter) Header() http.Header {
return w.resp.Header()
}
func (w *multiResponseWriter) Write(b []byte) (int, error) {
// Don't write large amounts of data to our temporary buffer
if w.body.Len()+len(b) > maxBodyBufferSize {
return w.resp.Write(b)
}
return w.multi.Write(b)
}
func (w *multiResponseWriter) WriteHeader(statusCode int) {
w.resp.WriteHeader(statusCode)
w.statusCode = statusCode
}
+23
View File
@@ -0,0 +1,23 @@
package service
import (
"context"
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
"github.com/fleetdm/fleet/v4/server/fleet"
)
func (svc *Service) ScimDetails(ctx context.Context) (fleet.ScimDetails, error) {
err := svc.authz.Authorize(ctx, &fleet.ScimUser{}, fleet.ActionRead)
if err != nil {
return fleet.ScimDetails{}, err
}
request, err := svc.ds.ScimLastRequest(ctx)
if err != nil {
return fleet.ScimDetails{}, ctxerr.Wrap(ctx, err, "scim details")
}
return fleet.ScimDetails{
LastRequest: request,
}, nil
}