mirror of
https://github.com/fleetdm/fleet
synced 2026-04-21 13:37:30 +00:00
<!-- Add the related story/sub-task/bug number, like Resolves #123, or remove if NA --> **Related issue:** Resolves #38889 PLEASE READ BELOW before looking at file changes Before converting individual files/packages to slog, we generally need to make these 2 changes to make the conversion easier: - Replace uses of `kitlog.With` since they are not fully compatible with our kitlog adapter - Directly use the kitlog adapter logger type instead of the kitlog interface, which will let us have direct access to the underlying slog logger: `*logging.Logger` Note: that I did not replace absolutely all uses of `kitlog.Logger`, but I did remove all uses of `kitlog.With` except for these due to complexity: - server/logging/filesystem.go and the other log writers (webhook, firehose, kinesis, lambda, pubsub, nats) - server/datastore/mysql/nanomdm_storage.go (adapter pattern) - server/vulnerabilities/nvd/* (cascades to CLI tools) - server/service/osquery_utils/queries.go (callback type signatures cascade broadly) - cmd/maintained-apps/ (standalone, so can be transitioned later all at once) Most of the changes in this PR follow these patterns: - `kitlog.Logger` type → `*logging.Logger` - `kitlog.With(logger, ...)` → `logger.With(...)` - `kitlog.NewNopLogger() → logging.NewNopLogger()`, including similar variations such as `logging.NewLogfmtLogger(w)` and `logging.NewJSONLogger(w)` - removed many now-unused kitlog imports Unique changes that the PR review should focus on: - server/platform/logging/kitlog_adapter.go: Core adapter changes - server/platform/logging/logging.go: New convenience functions - server/service/integration_logger_test.go: Test changes for slog # Checklist for submitter If some of the following don't apply, delete the relevant line. - [x] Changes file added for user-visible changes in `changes/`, `orbit/changes/` or `ee/fleetd-chrome/changes`. - Was added in previous PR ## Testing - [x] Added/updated automated tests - [x] QA'd all new/changed functionality manually <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Refactor** * Migrated the codebase to a unified internal structured logging system for more consistent, reliable logs and observability. * No user-facing functionality changed; runtime behavior and APIs remain compatible. * **Tests** * Updated tests to use the new logging helpers to ensure consistent test logging and validation. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
448 lines
17 KiB
Go
448 lines
17 KiB
Go
package scim
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"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/platform/logging"
|
|
"github.com/fleetdm/fleet/v4/server/service/middleware/auth"
|
|
"github.com/fleetdm/fleet/v4/server/service/middleware/log"
|
|
kitlog "github.com/go-kit/log"
|
|
"github.com/go-kit/log/level"
|
|
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
|
|
)
|
|
|
|
const (
|
|
maxResults = 100
|
|
)
|
|
|
|
func RegisterSCIM(
|
|
mux *http.ServeMux,
|
|
ds fleet.Datastore,
|
|
svc fleet.Service,
|
|
logger *logging.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{
|
|
Attributes: []schema.CoreAttribute{
|
|
schema.SimpleCoreAttribute(schema.SimpleStringParams(schema.StringParams{
|
|
Name: "department",
|
|
Required: false,
|
|
})),
|
|
},
|
|
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, 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
|
|
}
|
|
|
|
// 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 = auth.AuthenticatedUserMiddleware(svc, scimErrorHandler, handler)
|
|
handler = LastRequestMiddleware(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)
|
|
})
|
|
}
|
|
|
|
// 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 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)
|
|
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)
|
|
if err != nil {
|
|
errorHandler(w, logger, err.Error(), http.StatusForbidden)
|
|
return
|
|
}
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
func errorHandler(w http.ResponseWriter, logger kitlog.Logger, detail string, status int) {
|
|
scimErr := scimerrors.ScimError{
|
|
Status: status,
|
|
Detail: detail,
|
|
}
|
|
raw, err := json.Marshal(scimErr)
|
|
if err != nil {
|
|
level.Error(logger).Log("msg", "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 {
|
|
level.Error(logger).Log("msg", "failed writing response", "err", err)
|
|
}
|
|
}
|
|
|
|
type scimErrorLogger struct {
|
|
kitlog.Logger
|
|
}
|
|
|
|
var _ scim.Logger = &scimErrorLogger{}
|
|
|
|
func (l *scimErrorLogger) Error(args ...interface{}) {
|
|
level.Error(l.Logger).Log(
|
|
"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
|
|
}
|