Files
Juan Fernandez 9f251c21fe Fix SCIM middleware persisting authorization failures to last_request
The LastRequestMiddleware already skipped 401 responses but not 403s, so
unauthorized users could overwrite the admin-visible SCIM status. Skip
both.
2026-07-21 11:32:36 -04:00

582 lines
23 KiB
Go

package scim
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"os"
"strings"
"github.com/elimity-com/scim"
scimerrors "github.com/elimity-com/scim/errors"
"github.com/elimity-com/scim/optional"
"github.com/elimity-com/scim/schema"
"github.com/fleetdm/fleet/v4/server/authz"
"github.com/fleetdm/fleet/v4/server/config"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/fleetdm/fleet/v4/server/service/middleware/auth"
"github.com/fleetdm/fleet/v4/server/service/middleware/log"
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
)
const (
maxResults = 100
)
func RegisterSCIM(
mux *http.ServeMux,
ds fleet.Datastore,
svc fleet.Service,
logger *slog.Logger,
fleetConfig *config.FleetConfig,
) error {
if fleetConfig == nil {
return errors.New("fleet config is nil")
}
config := scim.ServiceProviderConfig{
DocumentationURI: optional.NewString("https://fleetdm.com/docs/get-started/why-fleet"),
MaxResults: maxResults,
SupportFiltering: true,
SupportPatch: true,
}
// The common attributes are id, externalId, and meta.
// In practice only meta.resourceType is required, while the other four (created, lastModified, location, and version) are not strictly required.
// RFC: https://tools.ietf.org/html/rfc7643#section-4.1
userSchema := schema.Schema{
ID: "urn:ietf:params:scim:schemas:core:2.0:User",
Name: optional.NewString("User"),
Description: optional.NewString("SCIM User"),
Attributes: []schema.CoreAttribute{
schema.SimpleCoreAttribute(schema.SimpleStringParams(schema.StringParams{
Name: "userName",
Required: true,
Uniqueness: schema.AttributeUniquenessServer(),
})),
schema.ComplexCoreAttribute(schema.ComplexParams{
Description: optional.NewString("The components of the user's real name. Providers MAY return just the full name as a single string in the formatted sub-attribute, or they MAY return just the individual component attributes using the other sub-attributes, or they MAY return both. If both variants are returned, they SHOULD be describing the same name, with the formatted name indicating how the component attributes should be combined."),
Name: "name",
SubAttributes: []schema.SimpleParams{
schema.SimpleStringParams(schema.StringParams{
Description: optional.NewString("The family name of the User, or last name in most Western languages (e.g., 'Jensen' given the full name 'Ms. Barbara J Jensen, III')."),
Name: "familyName",
Required: true,
}),
schema.SimpleStringParams(schema.StringParams{
Description: optional.NewString("The given name of the User, or first name in most Western languages (e.g., 'Barbara' given the full name 'Ms. Barbara J Jensen, III')."),
Name: "givenName",
Required: true,
}),
},
}),
schema.ComplexCoreAttribute(schema.ComplexParams{
Description: optional.NewString("Email addresses for the user. The value SHOULD be canonicalized by the service provider, e.g., 'bjensen@example.com' instead of 'bjensen@EXAMPLE.COM'. Canonical type values of 'work', 'home', and 'other'."),
MultiValued: true,
Name: "emails",
SubAttributes: []schema.SimpleParams{
schema.SimpleStringParams(schema.StringParams{
Description: optional.NewString("Email addresses for the user. The value SHOULD be canonicalized by the service provider, e.g., 'bjensen@example.com' instead of 'bjensen@EXAMPLE.COM'. Canonical type values of 'work', 'home', and 'other'."),
Name: "value",
}),
schema.SimpleStringParams(schema.StringParams{
CanonicalValues: []string{"work", "home", "other"},
Description: optional.NewString("A label indicating the attribute's function, e.g., 'work' or 'home'."),
Name: "type",
}),
schema.SimpleBooleanParams(schema.BooleanParams{
Description: optional.NewString("A Boolean value indicating the 'primary' or preferred attribute value for this attribute, e.g., the preferred mailing address or primary email address. The primary attribute value 'true' MUST appear no more than once."),
Name: "primary",
}),
},
}),
schema.SimpleCoreAttribute(schema.SimpleBooleanParams(schema.BooleanParams{
Description: optional.NewString("A Boolean value indicating the User's administrative status."),
Name: "active",
})),
schema.ComplexCoreAttribute(schema.ComplexParams{
Description: optional.NewString("A list of groups to which the user belongs, either through direct membership, through nested groups, or dynamically calculated."),
MultiValued: true,
Mutability: schema.AttributeMutabilityReadOnly(),
Name: "groups",
SubAttributes: []schema.SimpleParams{
schema.SimpleStringParams(schema.StringParams{
Description: optional.NewString("The identifier of the User's group."),
Mutability: schema.AttributeMutabilityReadOnly(),
Name: "value",
}),
schema.SimpleReferenceParams(schema.ReferenceParams{
Description: optional.NewString("The URI of the corresponding 'Group' resource to which the user belongs."),
Mutability: schema.AttributeMutabilityReadOnly(),
Name: "$ref",
ReferenceTypes: []schema.AttributeReferenceType{"Group"},
}),
schema.SimpleStringParams(schema.StringParams{
Description: optional.NewString("A human-readable name, primarily used for display purposes. READ-ONLY."),
Mutability: schema.AttributeMutabilityReadOnly(),
Name: "display",
}),
},
}),
},
}
// RFC: https://tools.ietf.org/html/rfc7643#section-4.2
groupSchema := schema.Schema{
ID: "urn:ietf:params:scim:schemas:core:2.0:Group",
Name: optional.NewString("Group"),
Description: optional.NewString("SCIM Group"),
Attributes: []schema.CoreAttribute{
schema.SimpleCoreAttribute(schema.SimpleStringParams(schema.StringParams{
Description: optional.NewString("A human-readable name for the Group. REQUIRED."),
Name: "displayName",
Required: true,
})),
schema.ComplexCoreAttribute(schema.ComplexParams{
Description: optional.NewString("A list of members of the Group."),
MultiValued: true,
Name: "members",
SubAttributes: []schema.SimpleParams{
schema.SimpleStringParams(schema.StringParams{
Description: optional.NewString("Identifier of the member of this Group."),
Mutability: schema.AttributeMutabilityImmutable(),
Name: "value",
}),
schema.SimpleStringParams(schema.StringParams{
CanonicalValues: []string{"User"},
Description: optional.NewString("A label indicating the type of resource, e.g., 'User' or 'Group'."),
Mutability: schema.AttributeMutabilityImmutable(),
Name: "type",
}),
// Note (2025/05/06): Microsoft does not properly support $ref attribute on group members
// https://learn.microsoft.com/en-us/answers/questions/1457148/scim-validator-patch-group-remove-member-test-comp
},
}),
},
}
scimLogger := logger.With("component", "SCIM")
resourceTypes := []scim.ResourceType{
{
ID: optional.NewString("User"),
Name: "User",
Endpoint: "/Users",
Description: optional.NewString("User Account"),
Schema: userSchema,
SchemaExtensions: []scim.SchemaExtension{
{
Schema: schema.Schema{
// Fleet stores only `department`, but we declare the full RFC 7643
// §4.3 enterprise attribute set so the elimity SCIM library accepts
// (rather than 400s) PATCH payloads from IdPs that bundle these
// alongside `department`. The handler reads only what it needs and
// silently drops the rest — see the `default` branches in
// UserHandler.Patch.
Attributes: []schema.CoreAttribute{
schema.SimpleCoreAttribute(schema.SimpleStringParams(schema.StringParams{
Name: "department",
Required: false,
})),
schema.SimpleCoreAttribute(schema.SimpleStringParams(schema.StringParams{
Name: "employeeNumber",
Required: false,
})),
schema.SimpleCoreAttribute(schema.SimpleStringParams(schema.StringParams{
Name: "costCenter",
Required: false,
})),
schema.SimpleCoreAttribute(schema.SimpleStringParams(schema.StringParams{
Name: "organization",
Required: false,
})),
schema.SimpleCoreAttribute(schema.SimpleStringParams(schema.StringParams{
Name: "division",
Required: false,
})),
schema.ComplexCoreAttribute(schema.ComplexParams{
Name: "manager",
Description: optional.NewString("The User's manager. A complex type that optionally allows service providers to represent organizational hierarchy by referencing the 'id' attribute of another User."),
SubAttributes: []schema.SimpleParams{
schema.SimpleStringParams(schema.StringParams{
Name: "value",
}),
schema.SimpleReferenceParams(schema.ReferenceParams{
Name: "$ref",
ReferenceTypes: []schema.AttributeReferenceType{"User"},
}),
// `displayName` is ReadOnly per RFC 7643 §4.3. The elimity
// library silently strips ReadOnly sub-attrs from client
// input, so we don't need to tolerate it in the handler.
schema.SimpleStringParams(schema.StringParams{
Name: "displayName",
Mutability: schema.AttributeMutabilityReadOnly(),
}),
},
}),
},
Description: optional.NewString("Enterprise User"),
ID: "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User",
Name: optional.NewString("Enterprise User"),
},
Required: false,
},
},
Handler: NewUserHandler(ds, svc.NewActivity, scimLogger),
},
{
ID: optional.NewString("Group"),
Name: "Group",
Endpoint: "/Groups",
Description: optional.NewString("Group"),
Schema: groupSchema,
Handler: NewGroupHandler(ds, scimLogger),
},
}
serverArgs := &scim.ServerArgs{
ServiceProviderConfig: &config,
ResourceTypes: resourceTypes,
}
serverOpts := []scim.ServerOption{
scim.WithLogger(&scimErrorLogger{logger: scimLogger}),
}
server, err := scim.NewServer(serverArgs, serverOpts...)
if err != nil {
return err
}
scimErrorHandler := func(w http.ResponseWriter, detail string, status int) {
errorHandler(w, scimLogger, detail, status)
}
authorizer, err := authz.NewAuthorizer()
if err != nil {
return err
}
dumpPayloadsEnabled := debugSCIMPayloadsEnabled()
// Apply middleware including OTEL instrumentation
applyMiddleware := func(prefix string, server http.Handler) http.Handler {
handler := http.StripPrefix(prefix, server)
handler = AuthorizationMiddleware(authorizer, scimLogger, handler)
handler = debugPayloadDumpMiddleware(scimLogger, dumpPayloadsEnabled, handler)
handler = auth.AuthenticatedUserMiddleware(svc, scimErrorHandler, handler)
handler = LastRequestMiddleware(ds, scimLogger, handler)
// Placed before (outside) LastRequestMiddleware so that ignored SCIM
// requests don't overwrite the last-sync status owned by the Google
// Workspace sync.
handler = GoogleWorkspaceExclusionMiddleware(ds, scimLogger, handler)
handler = log.LogResponseEndMiddleware(scimLogger, handler)
handler = auth.SetRequestsContextMiddleware(svc, handler)
return handler
}
// We cannot use Go URL path pattern like {version} because the http.StripPrefix method
// that gets us to the root SCIM path does not support wildcards: https://github.com/golang/go/issues/64909
// Apply OTEL instrumentation at the mux level (outermost)
mux.Handle("/api/v1/fleet/scim/", scimOTELMiddleware(applyMiddleware("/api/v1/fleet/scim", server), "/api/v1/fleet/scim", *fleetConfig))
mux.Handle("/api/latest/fleet/scim/", scimOTELMiddleware(applyMiddleware("/api/latest/fleet/scim", server), "/api/latest/fleet/scim", *fleetConfig))
return nil
}
// scimOTELMiddleware provides OpenTelemetry instrumentation for SCIM endpoints
// It creates proper span names without exposing sensitive IDs
func scimOTELMiddleware(next http.Handler, prefix string, cfg config.FleetConfig) http.Handler {
if !cfg.Logging.TracingEnabled || cfg.Logging.TracingType != "opentelemetry" {
return next
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Determine the SCIM route pattern based on the path
// OTEL is the outermost middleware, so we see the full path including prefix
fullPath := r.URL.Path
// Remove the prefix to get the SCIM-specific path
scimPath := strings.TrimPrefix(fullPath, prefix)
// Handle both "/Schemas" and "Schemas" by trimming the leading slash
scimPath = strings.TrimPrefix(scimPath, "/")
var route string
// Normalize the path to create a route pattern without exposing IDs
switch {
case strings.HasPrefix(scimPath, "Users"):
segments := strings.Split(scimPath, "/")
if len(segments) == 1 || (len(segments) == 2 && segments[1] == "") {
route = prefix + "/Users"
} else {
// Individual user operations - don't expose the user ID
route = prefix + "/Users/{id}"
}
case strings.HasPrefix(scimPath, "Groups"):
segments := strings.Split(scimPath, "/")
if len(segments) == 1 || (len(segments) == 2 && segments[1] == "") {
route = prefix + "/Groups"
} else {
// Individual group operations - don't expose the group ID
route = prefix + "/Groups/{id}"
}
case strings.HasPrefix(scimPath, "Schemas"):
segments := strings.Split(scimPath, "/")
if len(segments) == 1 || (len(segments) == 2 && segments[1] == "") {
route = prefix + "/Schemas"
} else {
route = prefix + "/Schemas/{id}"
}
case scimPath == "ServiceProviderConfig" || scimPath == "ServiceProviderConfig/":
route = prefix + "/ServiceProviderConfig"
case scimPath == "ResourceTypes" || scimPath == "ResourceTypes/":
route = prefix + "/ResourceTypes"
default:
// For any other path, use the full path but check for potential IDs
// If the path looks like it might contain an ID (has multiple segments),
// we should sanitize it
segments := strings.Split(strings.Trim(scimPath, "/"), "/")
if len(segments) > 1 {
// Might be something like CustomResource/123
// Replace the last segment with {id} if it looks like an ID
route = prefix + "/" + segments[0] + "/{id}"
} else {
// Single segment path, use as is
route = prefix + "/" + scimPath
}
}
// Create the instrumented handler with the proper route
instrumentedHandler := otelhttp.NewHandler(
otelhttp.WithRouteTag(route, next),
"", // Empty operation name - will be set by span name formatter
otelhttp.WithSpanNameFormatter(func(operation string, req *http.Request) string {
return req.Method + " " + route
}),
)
instrumentedHandler.ServeHTTP(w, r)
})
}
// debugSCIMPayloadsEnabled reports whether the FLEET_DEBUG_SCIM_PAYLOADS environment
// variable is set to a truthy value.
func debugSCIMPayloadsEnabled() bool {
switch strings.ToLower(strings.TrimSpace(os.Getenv("FLEET_DEBUG_SCIM_PAYLOADS"))) {
case "1", "true", "yes", "on":
return true
}
return false
}
// debugPayloadDumpMiddleware logs the raw SCIM request body to scimLogger when enabled
// is true. The full body is read into memory, written to the log, and then restored
// for the downstream handler via a fresh io.ReadCloser.
func debugPayloadDumpMiddleware(logger *slog.Logger, enabled bool, next http.Handler) http.Handler {
if !enabled {
return next
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
if r.Body == nil || r.Body == http.NoBody {
logger.WarnContext(ctx, "scim payload dump",
"method", r.Method, "path", r.URL.Path, "size", 0, "body", "")
next.ServeHTTP(w, r)
return
}
body, err := io.ReadAll(r.Body)
_ = r.Body.Close()
if err != nil {
// Fail the request explicitly rather than passing an empty body to the
// downstream handler — that would surface as a confusing JSON parse
// error and mask the real read failure.
logger.ErrorContext(ctx, "scim payload dump: failed to read body — failing request to surface error",
"method", r.Method, "path", r.URL.Path, "err", err)
http.Error(w, "internal error reading request body", http.StatusInternalServerError)
return
}
logger.WarnContext(ctx, "scim payload dump",
"method", r.Method, "path", r.URL.Path,
"size", len(body), "body", string(body))
r.Body = io.NopCloser(bytes.NewReader(body))
next.ServeHTTP(w, r)
})
}
// GoogleWorkspaceExclusionMiddleware short-circuits SCIM requests when a Google
// Workspace integration is configured. Google Workspace and SCIM are mutually
// exclusive sources for IdP host vitals: while Google Workspace is configured,
// Fleet pulls the directory itself and must ignore SCIM pushes so they cannot
// clobber the synced data. It is placed before LastRequestMiddleware so ignored
// requests don't overwrite the last-sync status.
func GoogleWorkspaceExclusionMiddleware(ds fleet.Datastore, logger *slog.Logger, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
appConfig, err := ds.AppConfig(ctx)
if err != nil {
// Fail open: if the (cached) config can't be read, fall back to normal
// SCIM handling rather than breaking provisioning on a transient error.
logger.ErrorContext(ctx, "scim: failed to load app config for google workspace exclusion", "err", err)
next.ServeHTTP(w, r)
return
}
if len(appConfig.Integrations.GoogleWorkspace) == 0 {
next.ServeHTTP(w, r)
return
}
logger.WarnContext(ctx, "ignoring SCIM request because a Google Workspace integration is configured",
"method", r.Method, "path", r.URL.Path)
w.Header().Set("Content-Type", "application/scim+json")
w.WriteHeader(http.StatusConflict)
_ = json.NewEncoder(w).Encode(map[string]any{
"schemas": []string{"urn:ietf:params:scim:api:messages:2.0:Error"},
"detail": "SCIM provisioning is disabled because a Google Workspace integration is configured in Fleet.",
"status": fmt.Sprintf("%d", http.StatusConflict),
})
})
}
// 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 *slog.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 || multi.statusCode == http.StatusForbidden:
// We do not save authentication (401) or authorization (403) failures; we
// simply log them. Otherwise an authenticated-but-unauthorized user (e.g. an
// observer) could overwrite the admin-visible last_request telemetry with
// their rejected attempts.
msg := "unauthenticated request"
if multi.statusCode == http.StatusForbidden {
msg = "unauthorized request"
}
logger.InfoContext(r.Context(), msg,
"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 scimerrors.ScimError
if err := json.Unmarshal(multi.body.Bytes(), &parsedScimError); err == nil {
details = parsedScimError.Detail
} else {
details = multi.body.String()
}
if multi.statusCode == scimerrors.ScimErrorInvalidValue.Status && details == scimerrors.ScimErrorInvalidValue.Detail &&
strings.Contains(r.URL.Path, "/Users") {
// We customize the error message here since we can't do it inside the 3rd party SCIM library.
details = `Missing required attributes. "userName", "givenName", and "familyName" are required. Please configure your identity provider to send required attributes to Fleet.`
}
default:
status = "error"
details = fmt.Sprintf("Unhandled status code: %d", multi.statusCode)
logger.ErrorContext(r.Context(), "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 {
logger.ErrorContext(r.Context(), "failed to update last scim request", "err", err)
}
})
}
func AuthorizationMiddleware(authorizer *authz.Authorizer, logger *slog.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)
if err != nil {
errorHandler(w, logger, err.Error(), http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}
func errorHandler(w http.ResponseWriter, logger *slog.Logger, detail string, status int) {
scimErr := scimerrors.ScimError{
Status: status,
Detail: detail,
}
raw, err := json.Marshal(scimErr)
if err != nil {
logger.ErrorContext(context.TODO(), "failed marshaling scim error", "scimError", scimErr, "err", err)
return
}
w.Header().Set("Content-Type", "application/scim+json")
w.WriteHeader(scimErr.Status)
_, err = w.Write(raw)
if err != nil {
logger.ErrorContext(context.TODO(), "failed writing response", "err", err)
}
}
type scimErrorLogger struct {
logger *slog.Logger
}
var _ scim.Logger = &scimErrorLogger{}
func (l *scimErrorLogger) Error(args ...interface{}) {
l.logger.ErrorContext(context.TODO(), 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
}