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:
@@ -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{}
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user