diff --git a/cmd/fleet/serve.go b/cmd/fleet/serve.go index f49b728053..521925749f 100644 --- a/cmd/fleet/serve.go +++ b/cmd/fleet/serve.go @@ -22,6 +22,7 @@ import ( "github.com/WatchBeam/clock" "github.com/e-dard/netbug" "github.com/fleetdm/fleet/v4/ee/server/licensing" + "github.com/fleetdm/fleet/v4/ee/server/scim" eeservice "github.com/fleetdm/fleet/v4/ee/server/service" "github.com/fleetdm/fleet/v4/ee/server/service/digicert" "github.com/fleetdm/fleet/v4/pkg/fleethttp" @@ -1171,11 +1172,14 @@ the way that the Fleet server works. } } - // SCEP proxy (for NDES, etc.) if license.IsPremium() { + // SCEP proxy (for NDES, etc.) if err = service.RegisterSCEPProxy(rootMux, ds, logger, nil); err != nil { initFatal(err, "setup SCEP proxy") } + if err = scim.RegisterSCIM(rootMux, ds, svc, logger); err != nil { + initFatal(err, "setup SCIM") + } } if config.Prometheus.BasicAuth.Username != "" && config.Prometheus.BasicAuth.Password != "" { diff --git a/docs/Contributing/MDM-SCIM-integration.md b/docs/Contributing/MDM-SCIM-integration.md new file mode 100644 index 0000000000..3baff570ec --- /dev/null +++ b/docs/Contributing/MDM-SCIM-integration.md @@ -0,0 +1,102 @@ +# SCIM (System for Cross-domain Identity Management) integration + +## Reference docs + +- [scim.cloud](https://scim.cloud/) +- [SCIM: Core Schema (RFC7643)](https://datatracker.ietf.org/doc/html/rfc7643) +- [SCIM: Protocol (RFC7644)](https://datatracker.ietf.org/doc/html/rfc7644) +- [scim Go library](https://github.com/elimity-com/scim) + +## Okta integration + +- https://developer.okta.com/docs/guides/scim-provisioning-integration-prepare/main/ + +### Testing Okta integration + +First, create at least one SCIM user: + +``` +POST https://localhost:8080/api/latest/fleet/scim/Users + +{ + "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"], + "userName": "test.user@okta.local", + "name": { + "givenName": "Test", + "familyName": "User" + }, + "emails": [{ + "primary": true, + "value": "test.user@okta.local", + "type": "work" + }], + "active": true +} +``` + +Run test using [Runscope](https://www.runscope.com/). See [instructions](https://developer.okta.com/docs/guides/scim-provisioning-integration-prepare/main/#test-your-scim-api). + +## Entra ID integration +- [SCIM guide](https://learn.microsoft.com/en-us/entra/identity/app-provisioning/use-scim-to-provision-users-and-groups) +- [SCIM validator](https://scimvalidator.microsoft.com/) + - Only test attributes that we implemented + +### Testing Entra ID integration + +Use [scimvalidator.microsoft.com](https://scimvalidator.microsoft.com/). Only test the attributes that we have implemented. To see our supported attributes, check the schema: + +``` +GET https://localhost:8080/api/latest/fleet/scim/Schemas +``` + +## Authentication + +We use same authentication as API. HTTP header: `Authorization: Bearer xyz` + +## Diagrams + +```mermaid +--- +title: Initial DB schema (not kept up to date) +--- +erDiagram + HOST_SCIM_USER { + host_id uint PK + scim_user_id uint PK "FK" + } + SCIM_USERS { + id uint PK + external_id *string "Index" + user_name string "Unique" + given_name *string + family_name *string + active *bool + } + SCIM_USER_EMAILS { + id uint PK + scim_user_id uint FK + type *string "Index" + email string "Index" + primary *bool + } + SCIM_USER_GROUP { + scim_user_id string PK "FK" + group_id uint PK "FK" + } + SCIM_GROUPS { + id uint PK + external_id *string "Index" + display_name string "Index" + } + HOST_SCIM_USER }o--|| SCIM_USERS : "multiple hosts can have the same SCIM user" + SCIM_USERS ||--o{ SCIM_USER_GROUP: "zero-to-many" + SCIM_USER_GROUP }o--|| SCIM_GROUPS: "zero-to-many" + SCIM_USERS ||--o{ SCIM_USER_EMAILS: "zero-to-many" + COMMENT { + _ _ "created_at and updated_at columns not shown" + } +``` + +## Notes + +- Okta and Entra ID do not support nested groups diff --git a/ee/server/scim/scim.go b/ee/server/scim/scim.go new file mode 100644 index 0000000000..ac8ed0c744 --- /dev/null +++ b/ee/server/scim/scim.go @@ -0,0 +1,180 @@ +package scim + +import ( + "encoding/json" + "fmt" + "net/http" + + "github.com/elimity-com/scim" + "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/fleet" + "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" +) + +const ( + maxResults = 1000 +) + +func RegisterSCIM( + mux *http.ServeMux, + ds fleet.Datastore, + svc fleet.Service, + logger kitlog.Logger, +) error { + config := scim.ServiceProviderConfig{ + // TODO: DocumentationURI and Authentication scheme + DocumentationURI: optional.NewString("https://fleetdm.com/docs/get-started/why-fleet"), + SupportFiltering: true, + SupportPatch: true, + MaxResults: maxResults, + } + + // 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. + 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", + }), + 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", + }), + }, + }), + 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", + })), + }, + } + + scimLogger := kitlog.With(logger, "component", "SCIM") + resourceTypes := []scim.ResourceType{ + { + ID: optional.NewString("User"), + Name: "User", + Endpoint: "/Users", + Description: optional.NewString("User Account"), + Schema: userSchema, + Handler: NewUserHandler(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 + } + + // TODO: Add APM/OpenTelemetry tracing and Prometheus middleware + 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 = log.LogResponseEndMiddleware(scimLogger, handler) + handler = auth.SetRequestsContextMiddleware(svc, handler) + return handler + } + + mux.Handle("/api/v1/fleet/scim/", applyMiddleware("/api/v1/fleet/scim", server)) + mux.Handle("/api/latest/fleet/scim/", applyMiddleware("/api/latest/fleet/scim", server)) + return nil +} + +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 := errors.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...), + ) +} diff --git a/ee/server/scim/users.go b/ee/server/scim/users.go new file mode 100644 index 0000000000..cbed010fdf --- /dev/null +++ b/ee/server/scim/users.go @@ -0,0 +1,455 @@ +package scim + +import ( + "fmt" + "net/http" + "net/url" + "strconv" + "strings" + "unicode" + + "github.com/elimity-com/scim" + "github.com/elimity-com/scim/errors" + "github.com/elimity-com/scim/optional" + "github.com/fleetdm/fleet/v4/server/fleet" + kitlog "github.com/go-kit/log" + "github.com/go-kit/log/level" + "github.com/scim2/filter-parser/v2" +) + +const ( + // Common attributes: https://datatracker.ietf.org/doc/html/rfc7643#section-3.1 + externalIdAttr = "externalId" + + // User attributes: https://datatracker.ietf.org/doc/html/rfc7643#section-4.1 + userNameAttr = "userName" + nameAttr = "name" + givenNameAttr = "givenName" + familyNameAttr = "familyName" + activeAttr = "active" + emailsAttr = "emails" +) + +type UserHandler struct { + ds fleet.Datastore + logger kitlog.Logger +} + +// Compile-time check +var _ scim.ResourceHandler = &UserHandler{} + +func NewUserHandler(ds fleet.Datastore, logger kitlog.Logger) scim.ResourceHandler { + return &UserHandler{ds: ds, logger: logger} +} + +func (u *UserHandler) Create(r *http.Request, attributes scim.ResourceAttributes) (scim.Resource, error) { + // Check for userName uniqueness + userName, err := getRequiredResource[string](attributes, userNameAttr) + if err != nil { + level.Error(u.logger).Log("msg", "failed to get userName", "err", err) + return scim.Resource{}, err + } + _, err = u.ds.ScimUserByUserName(r.Context(), userName) + if !fleet.IsNotFound(err) { + level.Info(u.logger).Log("msg", "user already exists", userNameAttr, userName) + return scim.Resource{}, errors.ScimErrorUniqueness + } + + user, err := createUserFromAttributes(attributes) + if err != nil { + level.Error(u.logger).Log("msg", "failed to create user from attributes", userNameAttr, userName, "err", err) + return scim.Resource{}, err + } + user.ID, err = u.ds.CreateScimUser(r.Context(), user) + if err != nil { + return scim.Resource{}, err + } + + return createUserResource(user), nil +} + +func createUserFromAttributes(attributes scim.ResourceAttributes) (*fleet.ScimUser, error) { + user := fleet.ScimUser{} + var err error + user.UserName, err = getRequiredResource[string](attributes, userNameAttr) + if err != nil { + return nil, err + } + user.ExternalID, err = getOptionalResource[string](attributes, externalIdAttr) + if err != nil { + return nil, err + } + user.Active, err = getOptionalResource[bool](attributes, activeAttr) + if err != nil { + return nil, err + } + name, err := getComplexResource(attributes, nameAttr) + if err != nil { + return nil, err + } + user.FamilyName, err = getOptionalResource[string](name, familyNameAttr) + if err != nil { + return nil, err + } + user.GivenName, err = getOptionalResource[string](name, givenNameAttr) + if err != nil { + return nil, err + } + emails, err := getComplexResourceSlice(attributes, emailsAttr) + if err != nil { + return nil, err + } + userEmails := make([]fleet.ScimUserEmail, 0, len(emails)) + for _, email := range emails { + userEmail := fleet.ScimUserEmail{} + userEmail.Email, err = getRequiredResource[string](email, "value") + if err != nil { + return nil, err + } + // Service providers SHOULD canonicalize the value according to [RFC5321] + // https://datatracker.ietf.org/doc/html/rfc7643#section-4.1.2 + userEmail.Email, err = normalizeEmail(userEmail.Email) + if err != nil { + return nil, errors.ScimErrorBadParams([]string{"value"}) + } + userEmail.Type, err = getOptionalResource[string](email, "type") + if err != nil { + return nil, err + } + userEmail.Primary, err = getOptionalResource[bool](email, "primary") + if err != nil { + return nil, err + } + userEmails = append(userEmails, userEmail) + } + user.Emails = userEmails + return &user, nil +} + +func getRequiredResource[T string | bool](attributes scim.ResourceAttributes, key string) (T, error) { + var val T + valIntf, ok := attributes[key] + if !ok || valIntf == nil { + return val, errors.ScimErrorBadParams([]string{key}) + } + val, ok = valIntf.(T) + if !ok { + return val, errors.ScimErrorBadParams([]string{key}) + } + return val, nil +} + +func getOptionalResource[T string | bool](attributes scim.ResourceAttributes, key string) (*T, error) { + var valPtr *T + valIntf, ok := attributes[key] + if ok && valIntf != nil { + val, ok := valIntf.(T) + if !ok { + return nil, errors.ScimErrorBadParams([]string{key}) + } + valPtr = &val + } + return valPtr, nil +} + +func getComplexResource(attributes scim.ResourceAttributes, key string) (map[string]interface{}, error) { + valIntf, ok := attributes[key] + if ok && valIntf != nil { + val, ok := valIntf.(map[string]interface{}) + if !ok { + return nil, errors.ScimErrorBadParams([]string{key}) + } + return val, nil + } + return nil, nil +} + +func getComplexResourceSlice(attributes scim.ResourceAttributes, key string) ([]map[string]interface{}, error) { + valIntf, ok := attributes[key] + if ok && valIntf != nil { + valSliceIntf, ok := valIntf.([]interface{}) + if !ok { + return nil, errors.ScimErrorBadParams([]string{key}) + } + val := make([]map[string]interface{}, 0, len(valSliceIntf)) + for _, v := range valSliceIntf { + valMap, ok := v.(map[string]interface{}) + if !ok { + return nil, errors.ScimErrorBadParams([]string{key}) + } + if len(valMap) > 0 { + val = append(val, valMap) + } + } + return val, nil + } + return nil, nil +} + +func (u *UserHandler) Get(r *http.Request, id string) (scim.Resource, error) { + idUint, err := strconv.ParseUint(id, 10, 64) + if err != nil { + level.Info(u.logger).Log("msg", "failed to parse id", "id", id, "err", err) + return scim.Resource{}, errors.ScimErrorResourceNotFound(id) + } + + user, err := u.ds.ScimUserByID(r.Context(), uint(idUint)) + switch { + case fleet.IsNotFound(err): + level.Info(u.logger).Log("msg", "failed to find user", "id", id) + return scim.Resource{}, errors.ScimErrorResourceNotFound(id) + case err != nil: + level.Error(u.logger).Log("msg", "failed to get user", "id", id, "err", err) + return scim.Resource{}, err + } + + return createUserResource(user), nil +} + +func createUserResource(user *fleet.ScimUser) scim.Resource { + userResource := scim.Resource{} + userResource.ID = fmt.Sprintf("%d", user.ID) + if user.ExternalID != nil { + userResource.ExternalID = optional.NewString(*user.ExternalID) + } + userResource.Attributes = scim.ResourceAttributes{} + userResource.Attributes[userNameAttr] = user.UserName + if user.Active != nil { + userResource.Attributes[activeAttr] = *user.Active + } + if user.FamilyName != nil || user.GivenName != nil { + userResource.Attributes[nameAttr] = make(scim.ResourceAttributes) + if user.FamilyName != nil { + userResource.Attributes[nameAttr].(scim.ResourceAttributes)[familyNameAttr] = *user.FamilyName + } + if user.GivenName != nil { + userResource.Attributes[nameAttr].(scim.ResourceAttributes)[givenNameAttr] = *user.GivenName + } + } + if len(user.Emails) > 0 { + emails := make([]scim.ResourceAttributes, 0, len(user.Emails)) + for _, email := range user.Emails { + emailResource := make(scim.ResourceAttributes) + emailResource["value"] = email.Email + if email.Type != nil { + emailResource["type"] = *email.Type + } + if email.Primary != nil { + emailResource["primary"] = *email.Primary + } + emails = append(emails, emailResource) + } + userResource.Attributes[emailsAttr] = emails + } + return userResource +} + +// GetAll +// Per RFC7644 3.4.2, SHOULD ignore any query parameters they do not recognize instead of rejecting the query for versioning compatibility reasons +// https://datatracker.ietf.org/doc/html/rfc7644#section-3.4.2 +// +// Providers MUST decline to filter results if the specified filter operation is not recognized and return an HTTP 400 error with a +// "scimType" error of "invalidFilter" and an appropriate human-readable response as per Section 3.12. For example, if a client specified an +// unsupported operator named 'regex', the service provider should specify an error response description identifying the client error, +// e.g., 'The operator 'regex' is not supported.' +// +// If a SCIM service provider determines that too many results would be returned the server base URI, the server SHALL reject the request by +// returning an HTTP response with HTTP status code 400 (Bad Request) and JSON attribute "scimType" set to "tooMany" (see Table 9). +// +// totalResults: The total number of results returned by the list or query operation. The value may be larger than the number of +// resources returned, such as when returning a single page (see Section 3.4.2.4) of results where multiple pages are available. +func (u *UserHandler) GetAll(r *http.Request, params scim.ListRequestParams) (scim.Page, error) { + page := params.StartIndex + if page < 1 { + page = 1 + } + count := params.Count + if count > maxResults { + return scim.Page{}, errors.ScimErrorTooMany + } + if count < 1 { + count = maxResults + } + + opts := fleet.ScimUsersListOptions{ + Page: uint(page), // nolint:gosec // ignore G115 + PerPage: uint(count), // nolint:gosec // ignore G115 + } + resourceFilter := r.URL.Query().Get("filter") + if resourceFilter != "" { + expr, err := filter.ParseAttrExp([]byte(resourceFilter)) + if err != nil { + level.Error(u.logger).Log("msg", "failed to parse filter", "filter", resourceFilter, "err", err) + return scim.Page{}, errors.ScimErrorInvalidFilter + } + if !strings.EqualFold(expr.AttributePath.String(), "userName") || expr.Operator != "eq" { + level.Info(u.logger).Log("msg", "unsupported filter", "filter", resourceFilter) + return scim.Page{}, nil + } + userName, ok := expr.CompareValue.(string) + if !ok { + level.Error(u.logger).Log("msg", "unsupported value", "value", expr.CompareValue) + return scim.Page{}, nil + } + + // Decode URL-encoded characters in userName, which is required to pass Microsoft Entra ID SCIM Validator + userName, err = url.QueryUnescape(userName) + if err != nil { + level.Error(u.logger).Log("msg", "failed to decode userName", "userName", userName, "err", err) + return scim.Page{}, nil + } + opts.UserNameFilter = &userName + } + users, totalResults, err := u.ds.ListScimUsers(r.Context(), opts) + if err != nil { + level.Error(u.logger).Log("msg", "failed to list users", "err", err) + return scim.Page{}, err + } + + result := scim.Page{ + TotalResults: int(totalResults), // nolint:gosec // ignore G115 + Resources: make([]scim.Resource, 0, len(users)), + } + for _, user := range users { + result.Resources = append(result.Resources, createUserResource(&user)) + } + + return result, nil +} + +func (u *UserHandler) Replace(r *http.Request, id string, attributes scim.ResourceAttributes) (scim.Resource, error) { + idUint, err := strconv.ParseUint(id, 10, 64) + if err != nil { + level.Info(u.logger).Log("msg", "failed to parse id", "id", id, "err", err) + return scim.Resource{}, errors.ScimErrorResourceNotFound(id) + } + + user, err := createUserFromAttributes(attributes) + if err != nil { + level.Error(u.logger).Log("msg", "failed to create user from attributes", "id", id, "err", err) + return scim.Resource{}, err + } + user.ID = uint(idUint) + err = u.ds.ReplaceScimUser(r.Context(), user) + switch { + case fleet.IsNotFound(err): + level.Info(u.logger).Log("msg", "failed to find user to replace", "id", id) + return scim.Resource{}, errors.ScimErrorResourceNotFound(id) + case err != nil: + level.Error(u.logger).Log("msg", "failed to replace user", "id", id, "err", err) + return scim.Resource{}, err + } + + return createUserResource(user), nil +} + +// Delete +// https://datatracker.ietf.org/doc/html/rfc7644#section-3.6 +// MUST return a 404 (Not Found) error code for all operations associated with the previously deleted resource +func (u *UserHandler) Delete(r *http.Request, id string) error { + idUint, err := strconv.ParseUint(id, 10, 64) + if err != nil { + level.Info(u.logger).Log("msg", "failed to parse id", "id", id, "err", err) + return errors.ScimErrorResourceNotFound(id) + } + err = u.ds.DeleteScimUser(r.Context(), uint(idUint)) + switch { + case fleet.IsNotFound(err): + level.Info(u.logger).Log("msg", "failed to find user to delete", "id", id) + return errors.ScimErrorResourceNotFound(id) + case err != nil: + level.Error(u.logger).Log("msg", "failed to delete user", "id", id, "err", err) + return err + } + return nil +} + +// Patch +// Okta only requires patching the "active" attribute: +// https://developer.okta.com/docs/api/openapi/okta-scim/guides/scim-20/#update-a-specific-user-patch +func (u *UserHandler) Patch(r *http.Request, id string, operations []scim.PatchOperation) (scim.Resource, error) { + idUint, err := strconv.ParseUint(id, 10, 64) + if err != nil { + level.Info(u.logger).Log("msg", "failed to parse id", "id", id, "err", err) + return scim.Resource{}, errors.ScimErrorResourceNotFound(id) + } + user, err := u.ds.ScimUserByID(r.Context(), uint(idUint)) + switch { + case fleet.IsNotFound(err): + level.Info(u.logger).Log("msg", "failed to find user to patch", "id", id) + return scim.Resource{}, errors.ScimErrorResourceNotFound(id) + case err != nil: + level.Error(u.logger).Log("msg", "failed to get user to patch", "id", id, "err", err) + return scim.Resource{}, err + } + + for _, op := range operations { + if op.Op != "replace" { + level.Info(u.logger).Log("msg", "unsupported patch operation", "op", op.Op) + return scim.Resource{}, errors.ScimErrorBadParams([]string{fmt.Sprintf("%v", op)}) + } + switch { + case op.Path == nil: + newValues, ok := op.Value.(map[string]interface{}) + if !ok { + level.Info(u.logger).Log("msg", "unsupported patch value", "value", op.Value) + return scim.Resource{}, errors.ScimErrorBadParams([]string{fmt.Sprintf("%v", op)}) + } + if len(newValues) != 1 { + level.Info(u.logger).Log("msg", "too many patch values", "value", op.Value) + return scim.Resource{}, errors.ScimErrorBadParams([]string{fmt.Sprintf("%v", op)}) + } + active, err := getRequiredResource[bool](newValues, activeAttr) + if err != nil { + level.Info(u.logger).Log("msg", "failed to get active value", "value", op.Value) + return scim.Resource{}, err + } + user.Active = &active + case op.Path.String() == activeAttr: + active, ok := op.Value.(bool) + if !ok { + level.Error(u.logger).Log("msg", "unsupported 'active' patch value", "value", op.Value) + return scim.Resource{}, errors.ScimErrorBadParams([]string{fmt.Sprintf("%v", op)}) + } + user.Active = &active + default: + level.Info(u.logger).Log("msg", "unsupported patch path", "path", op.Path) + return scim.Resource{}, errors.ScimErrorBadParams([]string{fmt.Sprintf("%v", op)}) + } + } + + err = u.ds.ReplaceScimUser(r.Context(), user) + switch { + case fleet.IsNotFound(err): + level.Info(u.logger).Log("msg", "failed to find user to patch", "id", id) + return scim.Resource{}, errors.ScimErrorResourceNotFound(id) + case err != nil: + level.Error(u.logger).Log("msg", "failed to patch user", "id", id, "err", err) + return scim.Resource{}, err + } + + return createUserResource(user), nil +} + +// normalizeEmail +// The local-part of a mailbox MUST BE treated as case sensitive. +// Mailbox domains follow normal DNS rules and are hence not case sensitive. +// https://datatracker.ietf.org/doc/html/rfc5321#section-2.4 +func normalizeEmail(email string) (string, error) { + email = removeWhitespace(email) + emailParts := strings.SplitN(email, "@", 2) + if len(emailParts) != 2 { + return "", fmt.Errorf("invalid email %s", email) + } + emailParts[1] = strings.ToLower(emailParts[1]) + return strings.Join(emailParts, "@"), nil +} + +func removeWhitespace(str string) string { + return strings.Map(func(r rune) rune { + if unicode.IsSpace(r) { + return -1 + } + return r + }, str) +} diff --git a/go.mod b/go.mod index e2692e597e..a071d3ea31 100644 --- a/go.mod +++ b/go.mod @@ -181,12 +181,15 @@ require ( github.com/cyphar/filepath-securejoin v0.2.5 // indirect github.com/dgraph-io/ristretto v0.1.0 // indirect github.com/dgryski/go-farm v0.0.0-20200201041132-a6ae2369ad13 // indirect + github.com/di-wu/parser v0.2.2 // indirect + github.com/di-wu/xsd-datetime v1.0.0 // indirect github.com/distribution/reference v0.6.0 // indirect github.com/docker/go-connections v0.4.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/edsrzf/mmap-go v1.1.0 // indirect github.com/elastic/go-sysinfo v1.11.2 // indirect github.com/elastic/go-windows v1.0.1 // indirect + github.com/elimity-com/scim v0.0.0-20240320110924-172bf2aee9c8 // indirect github.com/emirpasic/gods v1.18.1 // indirect github.com/fatih/structs v1.1.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect @@ -249,6 +252,7 @@ require ( github.com/prometheus/procfs v0.12.0 // indirect github.com/rcrowley/go-metrics v0.0.0-20200313005456-10cdbea86bc0 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect + github.com/scim2/filter-parser/v2 v2.2.0 // indirect github.com/secDre4mer/pkcs7 v0.0.0-20240322103146-665324a4461d // indirect github.com/secure-systems-lab/go-securesystemslib v0.5.0 // indirect github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect diff --git a/go.sum b/go.sum index dc70761a34..bd5a00d48e 100644 --- a/go.sum +++ b/go.sum @@ -219,6 +219,10 @@ github.com/dgryski/go-farm v0.0.0-20200201041132-a6ae2369ad13/go.mod h1:SqUrOPUn github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no= github.com/dgryski/trifles v0.0.0-20200323201526-dd97f9abfb48 h1:fRzb/w+pyskVMQ+UbP35JkH8yB7MYb4q/qhBarqZE6g= github.com/dgryski/trifles v0.0.0-20200323201526-dd97f9abfb48/go.mod h1:if7Fbed8SFyPtHLHbg49SI7NAdJiC5WIA09pe59rfAA= +github.com/di-wu/parser v0.2.2 h1:I9oHJ8spBXOeL7Wps0ffkFFFiXJf/pk7NX9lcAMqRMU= +github.com/di-wu/parser v0.2.2/go.mod h1:SLp58pW6WamdmznrVRrw2NTyn4wAvT9rrEFynKX7nYo= +github.com/di-wu/xsd-datetime v1.0.0 h1:vZoGNkbzpBNoc+JyfVLEbutNDNydYV8XwHeV7eUJoxI= +github.com/di-wu/xsd-datetime v1.0.0/go.mod h1:i3iEhrP3WchwseOBeIdW/zxeoleXTOzx1WyDXgdmOww= github.com/digitalocean/go-smbios v0.0.0-20180907143718-390a4f403a8e h1:vUmf0yezR0y7jJ5pceLHthLaYf4bA5T14B6q39S4q2Q= github.com/digitalocean/go-smbios v0.0.0-20180907143718-390a4f403a8e/go.mod h1:YTIHhz/QFSYnu/EhlF2SpU2Uk+32abacUYA5ZPljz1A= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= @@ -246,6 +250,8 @@ github.com/elazarl/go-bindata-assetfs v1.0.1 h1:m0kkaHRKEu7tUIUFVwhGGGYClXvyl4RE github.com/elazarl/go-bindata-assetfs v1.0.1/go.mod h1:v+YaWX3bdea5J/mo8dSETolEo7R71Vk1u8bnjau5yw4= github.com/elazarl/goproxy v1.2.1 h1:njjgvO6cRG9rIqN2ebkqy6cQz2Njkx7Fsfv/zIZqgug= github.com/elazarl/goproxy v1.2.1/go.mod h1:YfEbZtqP4AetfO6d40vWchF3znWX7C7Vd6ZMfdL8z64= +github.com/elimity-com/scim v0.0.0-20240320110924-172bf2aee9c8 h1:0+BTyxIYgiVAry/P5s8R4dYuLkhB9Nhso8ogFWNr4IQ= +github.com/elimity-com/scim v0.0.0-20240320110924-172bf2aee9c8/go.mod h1:JkjcmqbLW+khwt2fmBPJFBhx2zGZ8XobRZ+O0VhlwWo= github.com/emirpasic/gods v1.12.0/go.mod h1:YfzfFFoVP/catgzJb4IKIqXjX78Ha8FMSDh3ymbK86o= github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= @@ -745,6 +751,8 @@ github.com/saferwall/pe v1.5.5 h1:GGbzKjXDm7i+1K6riOgtgblyTdRmTbr3r11IzjovAK8= github.com/saferwall/pe v1.5.5/go.mod h1:mJx+PuptmNpoPFBNhWs/uDMFL/kTHVZIkg0d4OUJFbQ= github.com/sassoftware/relic/v8 v8.0.1 h1:uYUoaoTQMs67up8/46NgrSxSftgfY4VWBusDVg56k7I= github.com/sassoftware/relic/v8 v8.0.1/go.mod h1:s/MwugRcovgYcNJNOyvLfqRHDX7iArHtFtUR9kEodz8= +github.com/scim2/filter-parser/v2 v2.2.0 h1:QGadEcsmypxg8gYChRSM2j1edLyE/2j72j+hdmI4BJM= +github.com/scim2/filter-parser/v2 v2.2.0/go.mod h1:jWnkDToqX/Y0ugz0P5VvpVEUKcWcyHHj+X+je9ce5JA= github.com/scjalliance/comshim v0.0.0-20230315213746-5e51f40bd3b9 h1:rc/CcqLH3lh8n+csdOuDfP+NuykE0U6AeYSJJHKDgSg= github.com/scjalliance/comshim v0.0.0-20230315213746-5e51f40bd3b9/go.mod h1:a/83NAfUXvEuLpmxDssAXxgUgrEy12MId3Wd7OTs76s= github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc= diff --git a/server/authz/policy.rego b/server/authz/policy.rego index 2a752a8a0c..e8e87884e4 100644 --- a/server/authz/policy.rego +++ b/server/authz/policy.rego @@ -1046,3 +1046,13 @@ allow { subject.global_role == admin action == [read, write][_] } + +## +# SCIM (System for Cross-domain Identity Management) +## +# Global admins and maintainers can access SCIM. +allow { + object.type == "scim_user" + subject.global_role == [admin, maintainer][_] + action == write +} diff --git a/server/datastore/mysql/migrations/tables/20250331042354_AddSCIMTables.go b/server/datastore/mysql/migrations/tables/20250331042354_AddSCIMTables.go new file mode 100644 index 0000000000..29bec08374 --- /dev/null +++ b/server/datastore/mysql/migrations/tables/20250331042354_AddSCIMTables.go @@ -0,0 +1,78 @@ +package tables + +import ( + "database/sql" + "fmt" +) + +func init() { + MigrationClient.AddMigration(Up_20250331042354, Down_20250331042354) +} + +func Up_20250331042354(tx *sql.Tx) error { + _, err := tx.Exec(` + CREATE TABLE IF NOT EXISTS scim_users ( + id int UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + external_id VARCHAR(255) NULL, + user_name VARCHAR(255) NOT NULL, + given_name VARCHAR(255) NULL, + family_name VARCHAR(255) NULL, + active TINYINT(1) NULL, + created_at DATETIME(6) NOT NULL DEFAULT NOW(6), + updated_at DATETIME(6) NOT NULL DEFAULT NOW(6) ON UPDATE NOW(6), + UNIQUE KEY idx_scim_users_user_name (user_name), + KEY idx_scim_users_external_id (external_id) + ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci; + + CREATE TABLE IF NOT EXISTS host_scim_user ( + host_id INT UNSIGNED NOT NULL, + scim_user_id INT UNSIGNED NOT NULL, + created_at DATETIME(6) NOT NULL DEFAULT NOW(6), + PRIMARY KEY (host_id, scim_user_id), + CONSTRAINT fk_host_scim_scim_user_id FOREIGN KEY (scim_user_id) REFERENCES scim_users (id) ON DELETE CASCADE + ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci; + + CREATE TABLE if NOT EXISTS scim_user_emails ( + -- Using BIGINT because we clear and repopulate the emails frequently (during user update) + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + scim_user_id INT UNSIGNED NOT NULL, + email VARCHAR(255) NOT NULL, + ` + "`primary`" + ` TINYINT(1) NULL, + type VARCHAR(31) NULL, + created_at DATETIME(6) NOT NULL DEFAULT NOW(6), + updated_at DATETIME(6) NOT NULL DEFAULT NOW(6) ON UPDATE NOW(6), + KEY idx_scim_user_emails_email_type(type, email), + CONSTRAINT fk_scim_user_emails_scim_user_id FOREIGN KEY (scim_user_id) REFERENCES scim_users (id) ON DELETE CASCADE + ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci; + + CREATE TABLE IF NOT EXISTS scim_groups ( + id int UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + external_id VARCHAR(255) NULL, + display_name VARCHAR(255) NOT NULL, + created_at DATETIME(6) NOT NULL DEFAULT NOW(6), + updated_at DATETIME(6) NOT NULL DEFAULT NOW(6) ON UPDATE NOW(6), + KEY idx_scim_groups_external_id (external_id), + -- Entra ID requires a unique display name + UNIQUE KEY idx_scim_groups_display_name (display_name) + ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci; + + CREATE TABLE IF NOT EXISTS scim_user_group ( + scim_user_id INT UNSIGNED NOT NULL, + group_id INT UNSIGNED NOT NULL, + created_at DATETIME(6) NOT NULL DEFAULT NOW(6), + PRIMARY KEY (scim_user_id, group_id), + CONSTRAINT fk_scim_user_group_scim_user_id FOREIGN KEY (scim_user_id) REFERENCES scim_users (id) ON DELETE CASCADE, + CONSTRAINT fk_scim_user_group_group_id FOREIGN KEY (group_id) REFERENCES scim_groups (id) ON DELETE CASCADE + ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci; + `) + + if err != nil { + return fmt.Errorf("failed to create scim tables: %s", err) + } + + return nil +} + +func Down_20250331042354(tx *sql.Tx) error { + return nil +} diff --git a/server/datastore/mysql/schema.sql b/server/datastore/mysql/schema.sql index cd82dcaf05..1d21c54f98 100644 --- a/server/datastore/mysql/schema.sql +++ b/server/datastore/mysql/schema.sql @@ -622,6 +622,17 @@ CREATE TABLE `host_orbit_info` ( /*!40101 SET character_set_client = @saved_cs_client */; /*!40101 SET @saved_cs_client = @@character_set_client */; /*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `host_scim_user` ( + `host_id` int unsigned NOT NULL, + `scim_user_id` int unsigned NOT NULL, + `created_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + PRIMARY KEY (`host_id`,`scim_user_id`), + KEY `fk_host_scim_scim_user_id` (`scim_user_id`), + CONSTRAINT `fk_host_scim_scim_user_id` FOREIGN KEY (`scim_user_id`) REFERENCES `scim_users` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; CREATE TABLE `host_script_results` ( `id` int unsigned NOT NULL AUTO_INCREMENT, `host_id` int unsigned NOT NULL, @@ -1206,9 +1217,9 @@ CREATE TABLE `migration_status_tables` ( `is_applied` tinyint(1) NOT NULL, `tstamp` timestamp NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`) -) /*!50100 TABLESPACE `innodb_system` */ ENGINE=InnoDB AUTO_INCREMENT=371 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +) /*!50100 TABLESPACE `innodb_system` */ ENGINE=InnoDB AUTO_INCREMENT=372 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; /*!40101 SET character_set_client = @saved_cs_client */; -INSERT INTO `migration_status_tables` VALUES (1,0,1,'2020-01-01 01:01:01'),(2,20161118193812,1,'2020-01-01 01:01:01'),(3,20161118211713,1,'2020-01-01 01:01:01'),(4,20161118212436,1,'2020-01-01 01:01:01'),(5,20161118212515,1,'2020-01-01 01:01:01'),(6,20161118212528,1,'2020-01-01 01:01:01'),(7,20161118212538,1,'2020-01-01 01:01:01'),(8,20161118212549,1,'2020-01-01 01:01:01'),(9,20161118212557,1,'2020-01-01 01:01:01'),(10,20161118212604,1,'2020-01-01 01:01:01'),(11,20161118212613,1,'2020-01-01 01:01:01'),(12,20161118212621,1,'2020-01-01 01:01:01'),(13,20161118212630,1,'2020-01-01 01:01:01'),(14,20161118212641,1,'2020-01-01 01:01:01'),(15,20161118212649,1,'2020-01-01 01:01:01'),(16,20161118212656,1,'2020-01-01 01:01:01'),(17,20161118212758,1,'2020-01-01 01:01:01'),(18,20161128234849,1,'2020-01-01 01:01:01'),(19,20161230162221,1,'2020-01-01 01:01:01'),(20,20170104113816,1,'2020-01-01 01:01:01'),(21,20170105151732,1,'2020-01-01 01:01:01'),(22,20170108191242,1,'2020-01-01 01:01:01'),(23,20170109094020,1,'2020-01-01 01:01:01'),(24,20170109130438,1,'2020-01-01 01:01:01'),(25,20170110202752,1,'2020-01-01 01:01:01'),(26,20170111133013,1,'2020-01-01 01:01:01'),(27,20170117025759,1,'2020-01-01 01:01:01'),(28,20170118191001,1,'2020-01-01 01:01:01'),(29,20170119234632,1,'2020-01-01 01:01:01'),(30,20170124230432,1,'2020-01-01 01:01:01'),(31,20170127014618,1,'2020-01-01 01:01:01'),(32,20170131232841,1,'2020-01-01 01:01:01'),(33,20170223094154,1,'2020-01-01 01:01:01'),(34,20170306075207,1,'2020-01-01 01:01:01'),(35,20170309100733,1,'2020-01-01 01:01:01'),(36,20170331111922,1,'2020-01-01 01:01:01'),(37,20170502143928,1,'2020-01-01 01:01:01'),(38,20170504130602,1,'2020-01-01 01:01:01'),(39,20170509132100,1,'2020-01-01 01:01:01'),(40,20170519105647,1,'2020-01-01 01:01:01'),(41,20170519105648,1,'2020-01-01 01:01:01'),(42,20170831234300,1,'2020-01-01 01:01:01'),(43,20170831234301,1,'2020-01-01 01:01:01'),(44,20170831234303,1,'2020-01-01 01:01:01'),(45,20171116163618,1,'2020-01-01 01:01:01'),(46,20171219164727,1,'2020-01-01 01:01:01'),(47,20180620164811,1,'2020-01-01 01:01:01'),(48,20180620175054,1,'2020-01-01 01:01:01'),(49,20180620175055,1,'2020-01-01 01:01:01'),(50,20191010101639,1,'2020-01-01 01:01:01'),(51,20191010155147,1,'2020-01-01 01:01:01'),(52,20191220130734,1,'2020-01-01 01:01:01'),(53,20200311140000,1,'2020-01-01 01:01:01'),(54,20200405120000,1,'2020-01-01 01:01:01'),(55,20200407120000,1,'2020-01-01 01:01:01'),(56,20200420120000,1,'2020-01-01 01:01:01'),(57,20200504120000,1,'2020-01-01 01:01:01'),(58,20200512120000,1,'2020-01-01 01:01:01'),(59,20200707120000,1,'2020-01-01 01:01:01'),(60,20201011162341,1,'2020-01-01 01:01:01'),(61,20201021104586,1,'2020-01-01 01:01:01'),(62,20201102112520,1,'2020-01-01 01:01:01'),(63,20201208121729,1,'2020-01-01 01:01:01'),(64,20201215091637,1,'2020-01-01 01:01:01'),(65,20210119174155,1,'2020-01-01 01:01:01'),(66,20210326182902,1,'2020-01-01 01:01:01'),(67,20210421112652,1,'2020-01-01 01:01:01'),(68,20210506095025,1,'2020-01-01 01:01:01'),(69,20210513115729,1,'2020-01-01 01:01:01'),(70,20210526113559,1,'2020-01-01 01:01:01'),(71,20210601000001,1,'2020-01-01 01:01:01'),(72,20210601000002,1,'2020-01-01 01:01:01'),(73,20210601000003,1,'2020-01-01 01:01:01'),(74,20210601000004,1,'2020-01-01 01:01:01'),(75,20210601000005,1,'2020-01-01 01:01:01'),(76,20210601000006,1,'2020-01-01 01:01:01'),(77,20210601000007,1,'2020-01-01 01:01:01'),(78,20210601000008,1,'2020-01-01 01:01:01'),(79,20210606151329,1,'2020-01-01 01:01:01'),(80,20210616163757,1,'2020-01-01 01:01:01'),(81,20210617174723,1,'2020-01-01 01:01:01'),(82,20210622160235,1,'2020-01-01 01:01:01'),(83,20210623100031,1,'2020-01-01 01:01:01'),(84,20210623133615,1,'2020-01-01 01:01:01'),(85,20210708143152,1,'2020-01-01 01:01:01'),(86,20210709124443,1,'2020-01-01 01:01:01'),(87,20210712155608,1,'2020-01-01 01:01:01'),(88,20210714102108,1,'2020-01-01 01:01:01'),(89,20210719153709,1,'2020-01-01 01:01:01'),(90,20210721171531,1,'2020-01-01 01:01:01'),(91,20210723135713,1,'2020-01-01 01:01:01'),(92,20210802135933,1,'2020-01-01 01:01:01'),(93,20210806112844,1,'2020-01-01 01:01:01'),(94,20210810095603,1,'2020-01-01 01:01:01'),(95,20210811150223,1,'2020-01-01 01:01:01'),(96,20210818151827,1,'2020-01-01 01:01:01'),(97,20210818151828,1,'2020-01-01 01:01:01'),(98,20210818182258,1,'2020-01-01 01:01:01'),(99,20210819131107,1,'2020-01-01 01:01:01'),(100,20210819143446,1,'2020-01-01 01:01:01'),(101,20210903132338,1,'2020-01-01 01:01:01'),(102,20210915144307,1,'2020-01-01 01:01:01'),(103,20210920155130,1,'2020-01-01 01:01:01'),(104,20210927143115,1,'2020-01-01 01:01:01'),(105,20210927143116,1,'2020-01-01 01:01:01'),(106,20211013133706,1,'2020-01-01 01:01:01'),(107,20211013133707,1,'2020-01-01 01:01:01'),(108,20211102135149,1,'2020-01-01 01:01:01'),(109,20211109121546,1,'2020-01-01 01:01:01'),(110,20211110163320,1,'2020-01-01 01:01:01'),(111,20211116184029,1,'2020-01-01 01:01:01'),(112,20211116184030,1,'2020-01-01 01:01:01'),(113,20211202092042,1,'2020-01-01 01:01:01'),(114,20211202181033,1,'2020-01-01 01:01:01'),(115,20211207161856,1,'2020-01-01 01:01:01'),(116,20211216131203,1,'2020-01-01 01:01:01'),(117,20211221110132,1,'2020-01-01 01:01:01'),(118,20220107155700,1,'2020-01-01 01:01:01'),(119,20220125105650,1,'2020-01-01 01:01:01'),(120,20220201084510,1,'2020-01-01 01:01:01'),(121,20220208144830,1,'2020-01-01 01:01:01'),(122,20220208144831,1,'2020-01-01 01:01:01'),(123,20220215152203,1,'2020-01-01 01:01:01'),(124,20220223113157,1,'2020-01-01 01:01:01'),(125,20220307104655,1,'2020-01-01 01:01:01'),(126,20220309133956,1,'2020-01-01 01:01:01'),(127,20220316155700,1,'2020-01-01 01:01:01'),(128,20220323152301,1,'2020-01-01 01:01:01'),(129,20220330100659,1,'2020-01-01 01:01:01'),(130,20220404091216,1,'2020-01-01 01:01:01'),(131,20220419140750,1,'2020-01-01 01:01:01'),(132,20220428140039,1,'2020-01-01 01:01:01'),(133,20220503134048,1,'2020-01-01 01:01:01'),(134,20220524102918,1,'2020-01-01 01:01:01'),(135,20220526123327,1,'2020-01-01 01:01:01'),(136,20220526123328,1,'2020-01-01 01:01:01'),(137,20220526123329,1,'2020-01-01 01:01:01'),(138,20220608113128,1,'2020-01-01 01:01:01'),(139,20220627104817,1,'2020-01-01 01:01:01'),(140,20220704101843,1,'2020-01-01 01:01:01'),(141,20220708095046,1,'2020-01-01 01:01:01'),(142,20220713091130,1,'2020-01-01 01:01:01'),(143,20220802135510,1,'2020-01-01 01:01:01'),(144,20220818101352,1,'2020-01-01 01:01:01'),(145,20220822161445,1,'2020-01-01 01:01:01'),(146,20220831100036,1,'2020-01-01 01:01:01'),(147,20220831100151,1,'2020-01-01 01:01:01'),(148,20220908181826,1,'2020-01-01 01:01:01'),(149,20220914154915,1,'2020-01-01 01:01:01'),(150,20220915165115,1,'2020-01-01 01:01:01'),(151,20220915165116,1,'2020-01-01 01:01:01'),(152,20220928100158,1,'2020-01-01 01:01:01'),(153,20221014084130,1,'2020-01-01 01:01:01'),(154,20221027085019,1,'2020-01-01 01:01:01'),(155,20221101103952,1,'2020-01-01 01:01:01'),(156,20221104144401,1,'2020-01-01 01:01:01'),(157,20221109100749,1,'2020-01-01 01:01:01'),(158,20221115104546,1,'2020-01-01 01:01:01'),(159,20221130114928,1,'2020-01-01 01:01:01'),(160,20221205112142,1,'2020-01-01 01:01:01'),(161,20221216115820,1,'2020-01-01 01:01:01'),(162,20221220195934,1,'2020-01-01 01:01:01'),(163,20221220195935,1,'2020-01-01 01:01:01'),(164,20221223174807,1,'2020-01-01 01:01:01'),(165,20221227163855,1,'2020-01-01 01:01:01'),(166,20221227163856,1,'2020-01-01 01:01:01'),(167,20230202224725,1,'2020-01-01 01:01:01'),(168,20230206163608,1,'2020-01-01 01:01:01'),(169,20230214131519,1,'2020-01-01 01:01:01'),(170,20230303135738,1,'2020-01-01 01:01:01'),(171,20230313135301,1,'2020-01-01 01:01:01'),(172,20230313141819,1,'2020-01-01 01:01:01'),(173,20230315104937,1,'2020-01-01 01:01:01'),(174,20230317173844,1,'2020-01-01 01:01:01'),(175,20230320133602,1,'2020-01-01 01:01:01'),(176,20230330100011,1,'2020-01-01 01:01:01'),(177,20230330134823,1,'2020-01-01 01:01:01'),(178,20230405232025,1,'2020-01-01 01:01:01'),(179,20230408084104,1,'2020-01-01 01:01:01'),(180,20230411102858,1,'2020-01-01 01:01:01'),(181,20230421155932,1,'2020-01-01 01:01:01'),(182,20230425082126,1,'2020-01-01 01:01:01'),(183,20230425105727,1,'2020-01-01 01:01:01'),(184,20230501154913,1,'2020-01-01 01:01:01'),(185,20230503101418,1,'2020-01-01 01:01:01'),(186,20230515144206,1,'2020-01-01 01:01:01'),(187,20230517140952,1,'2020-01-01 01:01:01'),(188,20230517152807,1,'2020-01-01 01:01:01'),(189,20230518114155,1,'2020-01-01 01:01:01'),(190,20230520153236,1,'2020-01-01 01:01:01'),(191,20230525151159,1,'2020-01-01 01:01:01'),(192,20230530122103,1,'2020-01-01 01:01:01'),(193,20230602111827,1,'2020-01-01 01:01:01'),(194,20230608103123,1,'2020-01-01 01:01:01'),(195,20230629140529,1,'2020-01-01 01:01:01'),(196,20230629140530,1,'2020-01-01 01:01:01'),(197,20230711144622,1,'2020-01-01 01:01:01'),(198,20230721135421,1,'2020-01-01 01:01:01'),(199,20230721161508,1,'2020-01-01 01:01:01'),(200,20230726115701,1,'2020-01-01 01:01:01'),(201,20230807100822,1,'2020-01-01 01:01:01'),(202,20230814150442,1,'2020-01-01 01:01:01'),(203,20230823122728,1,'2020-01-01 01:01:01'),(204,20230906152143,1,'2020-01-01 01:01:01'),(205,20230911163618,1,'2020-01-01 01:01:01'),(206,20230912101759,1,'2020-01-01 01:01:01'),(207,20230915101341,1,'2020-01-01 01:01:01'),(208,20230918132351,1,'2020-01-01 01:01:01'),(209,20231004144339,1,'2020-01-01 01:01:01'),(210,20231009094541,1,'2020-01-01 01:01:01'),(211,20231009094542,1,'2020-01-01 01:01:01'),(212,20231009094543,1,'2020-01-01 01:01:01'),(213,20231009094544,1,'2020-01-01 01:01:01'),(214,20231016091915,1,'2020-01-01 01:01:01'),(215,20231024174135,1,'2020-01-01 01:01:01'),(216,20231025120016,1,'2020-01-01 01:01:01'),(217,20231025160156,1,'2020-01-01 01:01:01'),(218,20231031165350,1,'2020-01-01 01:01:01'),(219,20231106144110,1,'2020-01-01 01:01:01'),(220,20231107130934,1,'2020-01-01 01:01:01'),(221,20231109115838,1,'2020-01-01 01:01:01'),(222,20231121054530,1,'2020-01-01 01:01:01'),(223,20231122101320,1,'2020-01-01 01:01:01'),(224,20231130132828,1,'2020-01-01 01:01:01'),(225,20231130132931,1,'2020-01-01 01:01:01'),(226,20231204155427,1,'2020-01-01 01:01:01'),(227,20231206142340,1,'2020-01-01 01:01:01'),(228,20231207102320,1,'2020-01-01 01:01:01'),(229,20231207102321,1,'2020-01-01 01:01:01'),(230,20231207133731,1,'2020-01-01 01:01:01'),(231,20231212094238,1,'2020-01-01 01:01:01'),(232,20231212095734,1,'2020-01-01 01:01:01'),(233,20231212161121,1,'2020-01-01 01:01:01'),(234,20231215122713,1,'2020-01-01 01:01:01'),(235,20231219143041,1,'2020-01-01 01:01:01'),(236,20231224070653,1,'2020-01-01 01:01:01'),(237,20240110134315,1,'2020-01-01 01:01:01'),(238,20240119091637,1,'2020-01-01 01:01:01'),(239,20240126020642,1,'2020-01-01 01:01:01'),(240,20240126020643,1,'2020-01-01 01:01:01'),(241,20240129162819,1,'2020-01-01 01:01:01'),(242,20240130115133,1,'2020-01-01 01:01:01'),(243,20240131083822,1,'2020-01-01 01:01:01'),(244,20240205095928,1,'2020-01-01 01:01:01'),(245,20240205121956,1,'2020-01-01 01:01:01'),(246,20240209110212,1,'2020-01-01 01:01:01'),(247,20240212111533,1,'2020-01-01 01:01:01'),(248,20240221112844,1,'2020-01-01 01:01:01'),(249,20240222073518,1,'2020-01-01 01:01:01'),(250,20240222135115,1,'2020-01-01 01:01:01'),(251,20240226082255,1,'2020-01-01 01:01:01'),(252,20240228082706,1,'2020-01-01 01:01:01'),(253,20240301173035,1,'2020-01-01 01:01:01'),(254,20240302111134,1,'2020-01-01 01:01:01'),(255,20240312103753,1,'2020-01-01 01:01:01'),(256,20240313143416,1,'2020-01-01 01:01:01'),(257,20240314085226,1,'2020-01-01 01:01:01'),(258,20240314151747,1,'2020-01-01 01:01:01'),(259,20240320145650,1,'2020-01-01 01:01:01'),(260,20240327115530,1,'2020-01-01 01:01:01'),(261,20240327115617,1,'2020-01-01 01:01:01'),(262,20240408085837,1,'2020-01-01 01:01:01'),(263,20240415104633,1,'2020-01-01 01:01:01'),(264,20240430111727,1,'2020-01-01 01:01:01'),(265,20240515200020,1,'2020-01-01 01:01:01'),(266,20240521143023,1,'2020-01-01 01:01:01'),(267,20240521143024,1,'2020-01-01 01:01:01'),(268,20240601174138,1,'2020-01-01 01:01:01'),(269,20240607133721,1,'2020-01-01 01:01:01'),(270,20240612150059,1,'2020-01-01 01:01:01'),(271,20240613162201,1,'2020-01-01 01:01:01'),(272,20240613172616,1,'2020-01-01 01:01:01'),(273,20240618142419,1,'2020-01-01 01:01:01'),(274,20240625093543,1,'2020-01-01 01:01:01'),(275,20240626195531,1,'2020-01-01 01:01:01'),(276,20240702123921,1,'2020-01-01 01:01:01'),(277,20240703154849,1,'2020-01-01 01:01:01'),(278,20240707134035,1,'2020-01-01 01:01:01'),(279,20240707134036,1,'2020-01-01 01:01:01'),(280,20240709124958,1,'2020-01-01 01:01:01'),(281,20240709132642,1,'2020-01-01 01:01:01'),(282,20240709183940,1,'2020-01-01 01:01:01'),(283,20240710155623,1,'2020-01-01 01:01:01'),(284,20240723102712,1,'2020-01-01 01:01:01'),(285,20240725152735,1,'2020-01-01 01:01:01'),(286,20240725182118,1,'2020-01-01 01:01:01'),(287,20240726100517,1,'2020-01-01 01:01:01'),(288,20240730171504,1,'2020-01-01 01:01:01'),(289,20240730174056,1,'2020-01-01 01:01:01'),(290,20240730215453,1,'2020-01-01 01:01:01'),(291,20240730374423,1,'2020-01-01 01:01:01'),(292,20240801115359,1,'2020-01-01 01:01:01'),(293,20240802101043,1,'2020-01-01 01:01:01'),(294,20240802113716,1,'2020-01-01 01:01:01'),(295,20240814135330,1,'2020-01-01 01:01:01'),(296,20240815000000,1,'2020-01-01 01:01:01'),(297,20240815000001,1,'2020-01-01 01:01:01'),(298,20240816103247,1,'2020-01-01 01:01:01'),(299,20240820091218,1,'2020-01-01 01:01:01'),(300,20240826111228,1,'2020-01-01 01:01:01'),(301,20240826160025,1,'2020-01-01 01:01:01'),(302,20240829165448,1,'2020-01-01 01:01:01'),(303,20240829165605,1,'2020-01-01 01:01:01'),(304,20240829165715,1,'2020-01-01 01:01:01'),(305,20240829165930,1,'2020-01-01 01:01:01'),(306,20240829170023,1,'2020-01-01 01:01:01'),(307,20240829170033,1,'2020-01-01 01:01:01'),(308,20240829170044,1,'2020-01-01 01:01:01'),(309,20240905105135,1,'2020-01-01 01:01:01'),(310,20240905140514,1,'2020-01-01 01:01:01'),(311,20240905200000,1,'2020-01-01 01:01:01'),(312,20240905200001,1,'2020-01-01 01:01:01'),(313,20241002104104,1,'2020-01-01 01:01:01'),(314,20241002104105,1,'2020-01-01 01:01:01'),(315,20241002104106,1,'2020-01-01 01:01:01'),(316,20241002210000,1,'2020-01-01 01:01:01'),(317,20241003145349,1,'2020-01-01 01:01:01'),(318,20241004005000,1,'2020-01-01 01:01:01'),(319,20241008083925,1,'2020-01-01 01:01:01'),(320,20241009090010,1,'2020-01-01 01:01:01'),(321,20241017163402,1,'2020-01-01 01:01:01'),(322,20241021224359,1,'2020-01-01 01:01:01'),(323,20241022140321,1,'2020-01-01 01:01:01'),(324,20241025111236,1,'2020-01-01 01:01:01'),(325,20241025112748,1,'2020-01-01 01:01:01'),(326,20241025141855,1,'2020-01-01 01:01:01'),(327,20241110152839,1,'2020-01-01 01:01:01'),(328,20241110152840,1,'2020-01-01 01:01:01'),(329,20241110152841,1,'2020-01-01 01:01:01'),(330,20241116233322,1,'2020-01-01 01:01:01'),(331,20241122171434,1,'2020-01-01 01:01:01'),(332,20241125150614,1,'2020-01-01 01:01:01'),(333,20241203125346,1,'2020-01-01 01:01:01'),(334,20241203130032,1,'2020-01-01 01:01:01'),(335,20241205122800,1,'2020-01-01 01:01:01'),(336,20241209164540,1,'2020-01-01 01:01:01'),(337,20241210140021,1,'2020-01-01 01:01:01'),(338,20241219180042,1,'2020-01-01 01:01:01'),(339,20241220100000,1,'2020-01-01 01:01:01'),(340,20241220114903,1,'2020-01-01 01:01:01'),(341,20241220114904,1,'2020-01-01 01:01:01'),(342,20241224000000,1,'2020-01-01 01:01:01'),(343,20241230000000,1,'2020-01-01 01:01:01'),(344,20241231112624,1,'2020-01-01 01:01:01'),(345,20250102121439,1,'2020-01-01 01:01:01'),(346,20250121094045,1,'2020-01-01 01:01:01'),(347,20250121094500,1,'2020-01-01 01:01:01'),(348,20250121094600,1,'2020-01-01 01:01:01'),(349,20250121094700,1,'2020-01-01 01:01:01'),(350,20250124194347,1,'2020-01-01 01:01:01'),(351,20250127162751,1,'2020-01-01 01:01:01'),(352,20250213104005,1,'2020-01-01 01:01:01'),(353,20250214205657,1,'2020-01-01 01:01:01'),(354,20250217093329,1,'2020-01-01 01:01:01'),(355,20250219090511,1,'2020-01-01 01:01:01'),(356,20250219100000,1,'2020-01-01 01:01:01'),(357,20250219142401,1,'2020-01-01 01:01:01'),(358,20250224184002,1,'2020-01-01 01:01:01'),(359,20250225085436,1,'2020-01-01 01:01:01'),(360,20250226000000,1,'2020-01-01 01:01:01'),(361,20250226153445,1,'2020-01-01 01:01:01'),(362,20250304162702,1,'2020-01-01 01:01:01'),(363,20250306144233,1,'2020-01-01 01:01:01'),(364,20250313163430,1,'2020-01-01 01:01:01'),(365,20250317130944,1,'2020-01-01 01:01:01'),(366,20250318165922,1,'2020-01-01 01:01:01'),(367,20250320132525,1,'2020-01-01 01:01:01'),(368,20250320200000,1,'2020-01-01 01:01:01'),(369,20250325122638,1,'2020-01-01 01:01:01'),(370,20250326161930,1,'2020-01-01 01:01:01'); +INSERT INTO `migration_status_tables` VALUES (1,0,1,'2020-01-01 01:01:01'),(2,20161118193812,1,'2020-01-01 01:01:01'),(3,20161118211713,1,'2020-01-01 01:01:01'),(4,20161118212436,1,'2020-01-01 01:01:01'),(5,20161118212515,1,'2020-01-01 01:01:01'),(6,20161118212528,1,'2020-01-01 01:01:01'),(7,20161118212538,1,'2020-01-01 01:01:01'),(8,20161118212549,1,'2020-01-01 01:01:01'),(9,20161118212557,1,'2020-01-01 01:01:01'),(10,20161118212604,1,'2020-01-01 01:01:01'),(11,20161118212613,1,'2020-01-01 01:01:01'),(12,20161118212621,1,'2020-01-01 01:01:01'),(13,20161118212630,1,'2020-01-01 01:01:01'),(14,20161118212641,1,'2020-01-01 01:01:01'),(15,20161118212649,1,'2020-01-01 01:01:01'),(16,20161118212656,1,'2020-01-01 01:01:01'),(17,20161118212758,1,'2020-01-01 01:01:01'),(18,20161128234849,1,'2020-01-01 01:01:01'),(19,20161230162221,1,'2020-01-01 01:01:01'),(20,20170104113816,1,'2020-01-01 01:01:01'),(21,20170105151732,1,'2020-01-01 01:01:01'),(22,20170108191242,1,'2020-01-01 01:01:01'),(23,20170109094020,1,'2020-01-01 01:01:01'),(24,20170109130438,1,'2020-01-01 01:01:01'),(25,20170110202752,1,'2020-01-01 01:01:01'),(26,20170111133013,1,'2020-01-01 01:01:01'),(27,20170117025759,1,'2020-01-01 01:01:01'),(28,20170118191001,1,'2020-01-01 01:01:01'),(29,20170119234632,1,'2020-01-01 01:01:01'),(30,20170124230432,1,'2020-01-01 01:01:01'),(31,20170127014618,1,'2020-01-01 01:01:01'),(32,20170131232841,1,'2020-01-01 01:01:01'),(33,20170223094154,1,'2020-01-01 01:01:01'),(34,20170306075207,1,'2020-01-01 01:01:01'),(35,20170309100733,1,'2020-01-01 01:01:01'),(36,20170331111922,1,'2020-01-01 01:01:01'),(37,20170502143928,1,'2020-01-01 01:01:01'),(38,20170504130602,1,'2020-01-01 01:01:01'),(39,20170509132100,1,'2020-01-01 01:01:01'),(40,20170519105647,1,'2020-01-01 01:01:01'),(41,20170519105648,1,'2020-01-01 01:01:01'),(42,20170831234300,1,'2020-01-01 01:01:01'),(43,20170831234301,1,'2020-01-01 01:01:01'),(44,20170831234303,1,'2020-01-01 01:01:01'),(45,20171116163618,1,'2020-01-01 01:01:01'),(46,20171219164727,1,'2020-01-01 01:01:01'),(47,20180620164811,1,'2020-01-01 01:01:01'),(48,20180620175054,1,'2020-01-01 01:01:01'),(49,20180620175055,1,'2020-01-01 01:01:01'),(50,20191010101639,1,'2020-01-01 01:01:01'),(51,20191010155147,1,'2020-01-01 01:01:01'),(52,20191220130734,1,'2020-01-01 01:01:01'),(53,20200311140000,1,'2020-01-01 01:01:01'),(54,20200405120000,1,'2020-01-01 01:01:01'),(55,20200407120000,1,'2020-01-01 01:01:01'),(56,20200420120000,1,'2020-01-01 01:01:01'),(57,20200504120000,1,'2020-01-01 01:01:01'),(58,20200512120000,1,'2020-01-01 01:01:01'),(59,20200707120000,1,'2020-01-01 01:01:01'),(60,20201011162341,1,'2020-01-01 01:01:01'),(61,20201021104586,1,'2020-01-01 01:01:01'),(62,20201102112520,1,'2020-01-01 01:01:01'),(63,20201208121729,1,'2020-01-01 01:01:01'),(64,20201215091637,1,'2020-01-01 01:01:01'),(65,20210119174155,1,'2020-01-01 01:01:01'),(66,20210326182902,1,'2020-01-01 01:01:01'),(67,20210421112652,1,'2020-01-01 01:01:01'),(68,20210506095025,1,'2020-01-01 01:01:01'),(69,20210513115729,1,'2020-01-01 01:01:01'),(70,20210526113559,1,'2020-01-01 01:01:01'),(71,20210601000001,1,'2020-01-01 01:01:01'),(72,20210601000002,1,'2020-01-01 01:01:01'),(73,20210601000003,1,'2020-01-01 01:01:01'),(74,20210601000004,1,'2020-01-01 01:01:01'),(75,20210601000005,1,'2020-01-01 01:01:01'),(76,20210601000006,1,'2020-01-01 01:01:01'),(77,20210601000007,1,'2020-01-01 01:01:01'),(78,20210601000008,1,'2020-01-01 01:01:01'),(79,20210606151329,1,'2020-01-01 01:01:01'),(80,20210616163757,1,'2020-01-01 01:01:01'),(81,20210617174723,1,'2020-01-01 01:01:01'),(82,20210622160235,1,'2020-01-01 01:01:01'),(83,20210623100031,1,'2020-01-01 01:01:01'),(84,20210623133615,1,'2020-01-01 01:01:01'),(85,20210708143152,1,'2020-01-01 01:01:01'),(86,20210709124443,1,'2020-01-01 01:01:01'),(87,20210712155608,1,'2020-01-01 01:01:01'),(88,20210714102108,1,'2020-01-01 01:01:01'),(89,20210719153709,1,'2020-01-01 01:01:01'),(90,20210721171531,1,'2020-01-01 01:01:01'),(91,20210723135713,1,'2020-01-01 01:01:01'),(92,20210802135933,1,'2020-01-01 01:01:01'),(93,20210806112844,1,'2020-01-01 01:01:01'),(94,20210810095603,1,'2020-01-01 01:01:01'),(95,20210811150223,1,'2020-01-01 01:01:01'),(96,20210818151827,1,'2020-01-01 01:01:01'),(97,20210818151828,1,'2020-01-01 01:01:01'),(98,20210818182258,1,'2020-01-01 01:01:01'),(99,20210819131107,1,'2020-01-01 01:01:01'),(100,20210819143446,1,'2020-01-01 01:01:01'),(101,20210903132338,1,'2020-01-01 01:01:01'),(102,20210915144307,1,'2020-01-01 01:01:01'),(103,20210920155130,1,'2020-01-01 01:01:01'),(104,20210927143115,1,'2020-01-01 01:01:01'),(105,20210927143116,1,'2020-01-01 01:01:01'),(106,20211013133706,1,'2020-01-01 01:01:01'),(107,20211013133707,1,'2020-01-01 01:01:01'),(108,20211102135149,1,'2020-01-01 01:01:01'),(109,20211109121546,1,'2020-01-01 01:01:01'),(110,20211110163320,1,'2020-01-01 01:01:01'),(111,20211116184029,1,'2020-01-01 01:01:01'),(112,20211116184030,1,'2020-01-01 01:01:01'),(113,20211202092042,1,'2020-01-01 01:01:01'),(114,20211202181033,1,'2020-01-01 01:01:01'),(115,20211207161856,1,'2020-01-01 01:01:01'),(116,20211216131203,1,'2020-01-01 01:01:01'),(117,20211221110132,1,'2020-01-01 01:01:01'),(118,20220107155700,1,'2020-01-01 01:01:01'),(119,20220125105650,1,'2020-01-01 01:01:01'),(120,20220201084510,1,'2020-01-01 01:01:01'),(121,20220208144830,1,'2020-01-01 01:01:01'),(122,20220208144831,1,'2020-01-01 01:01:01'),(123,20220215152203,1,'2020-01-01 01:01:01'),(124,20220223113157,1,'2020-01-01 01:01:01'),(125,20220307104655,1,'2020-01-01 01:01:01'),(126,20220309133956,1,'2020-01-01 01:01:01'),(127,20220316155700,1,'2020-01-01 01:01:01'),(128,20220323152301,1,'2020-01-01 01:01:01'),(129,20220330100659,1,'2020-01-01 01:01:01'),(130,20220404091216,1,'2020-01-01 01:01:01'),(131,20220419140750,1,'2020-01-01 01:01:01'),(132,20220428140039,1,'2020-01-01 01:01:01'),(133,20220503134048,1,'2020-01-01 01:01:01'),(134,20220524102918,1,'2020-01-01 01:01:01'),(135,20220526123327,1,'2020-01-01 01:01:01'),(136,20220526123328,1,'2020-01-01 01:01:01'),(137,20220526123329,1,'2020-01-01 01:01:01'),(138,20220608113128,1,'2020-01-01 01:01:01'),(139,20220627104817,1,'2020-01-01 01:01:01'),(140,20220704101843,1,'2020-01-01 01:01:01'),(141,20220708095046,1,'2020-01-01 01:01:01'),(142,20220713091130,1,'2020-01-01 01:01:01'),(143,20220802135510,1,'2020-01-01 01:01:01'),(144,20220818101352,1,'2020-01-01 01:01:01'),(145,20220822161445,1,'2020-01-01 01:01:01'),(146,20220831100036,1,'2020-01-01 01:01:01'),(147,20220831100151,1,'2020-01-01 01:01:01'),(148,20220908181826,1,'2020-01-01 01:01:01'),(149,20220914154915,1,'2020-01-01 01:01:01'),(150,20220915165115,1,'2020-01-01 01:01:01'),(151,20220915165116,1,'2020-01-01 01:01:01'),(152,20220928100158,1,'2020-01-01 01:01:01'),(153,20221014084130,1,'2020-01-01 01:01:01'),(154,20221027085019,1,'2020-01-01 01:01:01'),(155,20221101103952,1,'2020-01-01 01:01:01'),(156,20221104144401,1,'2020-01-01 01:01:01'),(157,20221109100749,1,'2020-01-01 01:01:01'),(158,20221115104546,1,'2020-01-01 01:01:01'),(159,20221130114928,1,'2020-01-01 01:01:01'),(160,20221205112142,1,'2020-01-01 01:01:01'),(161,20221216115820,1,'2020-01-01 01:01:01'),(162,20221220195934,1,'2020-01-01 01:01:01'),(163,20221220195935,1,'2020-01-01 01:01:01'),(164,20221223174807,1,'2020-01-01 01:01:01'),(165,20221227163855,1,'2020-01-01 01:01:01'),(166,20221227163856,1,'2020-01-01 01:01:01'),(167,20230202224725,1,'2020-01-01 01:01:01'),(168,20230206163608,1,'2020-01-01 01:01:01'),(169,20230214131519,1,'2020-01-01 01:01:01'),(170,20230303135738,1,'2020-01-01 01:01:01'),(171,20230313135301,1,'2020-01-01 01:01:01'),(172,20230313141819,1,'2020-01-01 01:01:01'),(173,20230315104937,1,'2020-01-01 01:01:01'),(174,20230317173844,1,'2020-01-01 01:01:01'),(175,20230320133602,1,'2020-01-01 01:01:01'),(176,20230330100011,1,'2020-01-01 01:01:01'),(177,20230330134823,1,'2020-01-01 01:01:01'),(178,20230405232025,1,'2020-01-01 01:01:01'),(179,20230408084104,1,'2020-01-01 01:01:01'),(180,20230411102858,1,'2020-01-01 01:01:01'),(181,20230421155932,1,'2020-01-01 01:01:01'),(182,20230425082126,1,'2020-01-01 01:01:01'),(183,20230425105727,1,'2020-01-01 01:01:01'),(184,20230501154913,1,'2020-01-01 01:01:01'),(185,20230503101418,1,'2020-01-01 01:01:01'),(186,20230515144206,1,'2020-01-01 01:01:01'),(187,20230517140952,1,'2020-01-01 01:01:01'),(188,20230517152807,1,'2020-01-01 01:01:01'),(189,20230518114155,1,'2020-01-01 01:01:01'),(190,20230520153236,1,'2020-01-01 01:01:01'),(191,20230525151159,1,'2020-01-01 01:01:01'),(192,20230530122103,1,'2020-01-01 01:01:01'),(193,20230602111827,1,'2020-01-01 01:01:01'),(194,20230608103123,1,'2020-01-01 01:01:01'),(195,20230629140529,1,'2020-01-01 01:01:01'),(196,20230629140530,1,'2020-01-01 01:01:01'),(197,20230711144622,1,'2020-01-01 01:01:01'),(198,20230721135421,1,'2020-01-01 01:01:01'),(199,20230721161508,1,'2020-01-01 01:01:01'),(200,20230726115701,1,'2020-01-01 01:01:01'),(201,20230807100822,1,'2020-01-01 01:01:01'),(202,20230814150442,1,'2020-01-01 01:01:01'),(203,20230823122728,1,'2020-01-01 01:01:01'),(204,20230906152143,1,'2020-01-01 01:01:01'),(205,20230911163618,1,'2020-01-01 01:01:01'),(206,20230912101759,1,'2020-01-01 01:01:01'),(207,20230915101341,1,'2020-01-01 01:01:01'),(208,20230918132351,1,'2020-01-01 01:01:01'),(209,20231004144339,1,'2020-01-01 01:01:01'),(210,20231009094541,1,'2020-01-01 01:01:01'),(211,20231009094542,1,'2020-01-01 01:01:01'),(212,20231009094543,1,'2020-01-01 01:01:01'),(213,20231009094544,1,'2020-01-01 01:01:01'),(214,20231016091915,1,'2020-01-01 01:01:01'),(215,20231024174135,1,'2020-01-01 01:01:01'),(216,20231025120016,1,'2020-01-01 01:01:01'),(217,20231025160156,1,'2020-01-01 01:01:01'),(218,20231031165350,1,'2020-01-01 01:01:01'),(219,20231106144110,1,'2020-01-01 01:01:01'),(220,20231107130934,1,'2020-01-01 01:01:01'),(221,20231109115838,1,'2020-01-01 01:01:01'),(222,20231121054530,1,'2020-01-01 01:01:01'),(223,20231122101320,1,'2020-01-01 01:01:01'),(224,20231130132828,1,'2020-01-01 01:01:01'),(225,20231130132931,1,'2020-01-01 01:01:01'),(226,20231204155427,1,'2020-01-01 01:01:01'),(227,20231206142340,1,'2020-01-01 01:01:01'),(228,20231207102320,1,'2020-01-01 01:01:01'),(229,20231207102321,1,'2020-01-01 01:01:01'),(230,20231207133731,1,'2020-01-01 01:01:01'),(231,20231212094238,1,'2020-01-01 01:01:01'),(232,20231212095734,1,'2020-01-01 01:01:01'),(233,20231212161121,1,'2020-01-01 01:01:01'),(234,20231215122713,1,'2020-01-01 01:01:01'),(235,20231219143041,1,'2020-01-01 01:01:01'),(236,20231224070653,1,'2020-01-01 01:01:01'),(237,20240110134315,1,'2020-01-01 01:01:01'),(238,20240119091637,1,'2020-01-01 01:01:01'),(239,20240126020642,1,'2020-01-01 01:01:01'),(240,20240126020643,1,'2020-01-01 01:01:01'),(241,20240129162819,1,'2020-01-01 01:01:01'),(242,20240130115133,1,'2020-01-01 01:01:01'),(243,20240131083822,1,'2020-01-01 01:01:01'),(244,20240205095928,1,'2020-01-01 01:01:01'),(245,20240205121956,1,'2020-01-01 01:01:01'),(246,20240209110212,1,'2020-01-01 01:01:01'),(247,20240212111533,1,'2020-01-01 01:01:01'),(248,20240221112844,1,'2020-01-01 01:01:01'),(249,20240222073518,1,'2020-01-01 01:01:01'),(250,20240222135115,1,'2020-01-01 01:01:01'),(251,20240226082255,1,'2020-01-01 01:01:01'),(252,20240228082706,1,'2020-01-01 01:01:01'),(253,20240301173035,1,'2020-01-01 01:01:01'),(254,20240302111134,1,'2020-01-01 01:01:01'),(255,20240312103753,1,'2020-01-01 01:01:01'),(256,20240313143416,1,'2020-01-01 01:01:01'),(257,20240314085226,1,'2020-01-01 01:01:01'),(258,20240314151747,1,'2020-01-01 01:01:01'),(259,20240320145650,1,'2020-01-01 01:01:01'),(260,20240327115530,1,'2020-01-01 01:01:01'),(261,20240327115617,1,'2020-01-01 01:01:01'),(262,20240408085837,1,'2020-01-01 01:01:01'),(263,20240415104633,1,'2020-01-01 01:01:01'),(264,20240430111727,1,'2020-01-01 01:01:01'),(265,20240515200020,1,'2020-01-01 01:01:01'),(266,20240521143023,1,'2020-01-01 01:01:01'),(267,20240521143024,1,'2020-01-01 01:01:01'),(268,20240601174138,1,'2020-01-01 01:01:01'),(269,20240607133721,1,'2020-01-01 01:01:01'),(270,20240612150059,1,'2020-01-01 01:01:01'),(271,20240613162201,1,'2020-01-01 01:01:01'),(272,20240613172616,1,'2020-01-01 01:01:01'),(273,20240618142419,1,'2020-01-01 01:01:01'),(274,20240625093543,1,'2020-01-01 01:01:01'),(275,20240626195531,1,'2020-01-01 01:01:01'),(276,20240702123921,1,'2020-01-01 01:01:01'),(277,20240703154849,1,'2020-01-01 01:01:01'),(278,20240707134035,1,'2020-01-01 01:01:01'),(279,20240707134036,1,'2020-01-01 01:01:01'),(280,20240709124958,1,'2020-01-01 01:01:01'),(281,20240709132642,1,'2020-01-01 01:01:01'),(282,20240709183940,1,'2020-01-01 01:01:01'),(283,20240710155623,1,'2020-01-01 01:01:01'),(284,20240723102712,1,'2020-01-01 01:01:01'),(285,20240725152735,1,'2020-01-01 01:01:01'),(286,20240725182118,1,'2020-01-01 01:01:01'),(287,20240726100517,1,'2020-01-01 01:01:01'),(288,20240730171504,1,'2020-01-01 01:01:01'),(289,20240730174056,1,'2020-01-01 01:01:01'),(290,20240730215453,1,'2020-01-01 01:01:01'),(291,20240730374423,1,'2020-01-01 01:01:01'),(292,20240801115359,1,'2020-01-01 01:01:01'),(293,20240802101043,1,'2020-01-01 01:01:01'),(294,20240802113716,1,'2020-01-01 01:01:01'),(295,20240814135330,1,'2020-01-01 01:01:01'),(296,20240815000000,1,'2020-01-01 01:01:01'),(297,20240815000001,1,'2020-01-01 01:01:01'),(298,20240816103247,1,'2020-01-01 01:01:01'),(299,20240820091218,1,'2020-01-01 01:01:01'),(300,20240826111228,1,'2020-01-01 01:01:01'),(301,20240826160025,1,'2020-01-01 01:01:01'),(302,20240829165448,1,'2020-01-01 01:01:01'),(303,20240829165605,1,'2020-01-01 01:01:01'),(304,20240829165715,1,'2020-01-01 01:01:01'),(305,20240829165930,1,'2020-01-01 01:01:01'),(306,20240829170023,1,'2020-01-01 01:01:01'),(307,20240829170033,1,'2020-01-01 01:01:01'),(308,20240829170044,1,'2020-01-01 01:01:01'),(309,20240905105135,1,'2020-01-01 01:01:01'),(310,20240905140514,1,'2020-01-01 01:01:01'),(311,20240905200000,1,'2020-01-01 01:01:01'),(312,20240905200001,1,'2020-01-01 01:01:01'),(313,20241002104104,1,'2020-01-01 01:01:01'),(314,20241002104105,1,'2020-01-01 01:01:01'),(315,20241002104106,1,'2020-01-01 01:01:01'),(316,20241002210000,1,'2020-01-01 01:01:01'),(317,20241003145349,1,'2020-01-01 01:01:01'),(318,20241004005000,1,'2020-01-01 01:01:01'),(319,20241008083925,1,'2020-01-01 01:01:01'),(320,20241009090010,1,'2020-01-01 01:01:01'),(321,20241017163402,1,'2020-01-01 01:01:01'),(322,20241021224359,1,'2020-01-01 01:01:01'),(323,20241022140321,1,'2020-01-01 01:01:01'),(324,20241025111236,1,'2020-01-01 01:01:01'),(325,20241025112748,1,'2020-01-01 01:01:01'),(326,20241025141855,1,'2020-01-01 01:01:01'),(327,20241110152839,1,'2020-01-01 01:01:01'),(328,20241110152840,1,'2020-01-01 01:01:01'),(329,20241110152841,1,'2020-01-01 01:01:01'),(330,20241116233322,1,'2020-01-01 01:01:01'),(331,20241122171434,1,'2020-01-01 01:01:01'),(332,20241125150614,1,'2020-01-01 01:01:01'),(333,20241203125346,1,'2020-01-01 01:01:01'),(334,20241203130032,1,'2020-01-01 01:01:01'),(335,20241205122800,1,'2020-01-01 01:01:01'),(336,20241209164540,1,'2020-01-01 01:01:01'),(337,20241210140021,1,'2020-01-01 01:01:01'),(338,20241219180042,1,'2020-01-01 01:01:01'),(339,20241220100000,1,'2020-01-01 01:01:01'),(340,20241220114903,1,'2020-01-01 01:01:01'),(341,20241220114904,1,'2020-01-01 01:01:01'),(342,20241224000000,1,'2020-01-01 01:01:01'),(343,20241230000000,1,'2020-01-01 01:01:01'),(344,20241231112624,1,'2020-01-01 01:01:01'),(345,20250102121439,1,'2020-01-01 01:01:01'),(346,20250121094045,1,'2020-01-01 01:01:01'),(347,20250121094500,1,'2020-01-01 01:01:01'),(348,20250121094600,1,'2020-01-01 01:01:01'),(349,20250121094700,1,'2020-01-01 01:01:01'),(350,20250124194347,1,'2020-01-01 01:01:01'),(351,20250127162751,1,'2020-01-01 01:01:01'),(352,20250213104005,1,'2020-01-01 01:01:01'),(353,20250214205657,1,'2020-01-01 01:01:01'),(354,20250217093329,1,'2020-01-01 01:01:01'),(355,20250219090511,1,'2020-01-01 01:01:01'),(356,20250219100000,1,'2020-01-01 01:01:01'),(357,20250219142401,1,'2020-01-01 01:01:01'),(358,20250224184002,1,'2020-01-01 01:01:01'),(359,20250225085436,1,'2020-01-01 01:01:01'),(360,20250226000000,1,'2020-01-01 01:01:01'),(361,20250226153445,1,'2020-01-01 01:01:01'),(362,20250304162702,1,'2020-01-01 01:01:01'),(363,20250306144233,1,'2020-01-01 01:01:01'),(364,20250313163430,1,'2020-01-01 01:01:01'),(365,20250317130944,1,'2020-01-01 01:01:01'),(366,20250318165922,1,'2020-01-01 01:01:01'),(367,20250320132525,1,'2020-01-01 01:01:01'),(368,20250320200000,1,'2020-01-01 01:01:01'),(369,20250325122638,1,'2020-01-01 01:01:01'),(370,20250326161930,1,'2020-01-01 01:01:01'),(371,20250331042354,1,'2020-01-01 01:01:01'); /*!40101 SET @saved_cs_client = @@character_set_client */; /*!50503 SET character_set_client = utf8mb4 */; CREATE TABLE `mobile_device_management_solutions` ( @@ -1751,6 +1762,63 @@ CREATE TABLE `scheduled_query_stats` ( /*!40101 SET character_set_client = @saved_cs_client */; /*!40101 SET @saved_cs_client = @@character_set_client */; /*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `scim_groups` ( + `id` int unsigned NOT NULL AUTO_INCREMENT, + `external_id` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `display_name` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL, + `created_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + `updated_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), + PRIMARY KEY (`id`), + UNIQUE KEY `idx_scim_groups_display_name` (`display_name`), + KEY `idx_scim_groups_external_id` (`external_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `scim_user_emails` ( + `id` bigint unsigned NOT NULL AUTO_INCREMENT, + `scim_user_id` int unsigned NOT NULL, + `email` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL, + `primary` tinyint(1) DEFAULT NULL, + `type` varchar(31) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `created_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + `updated_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), + PRIMARY KEY (`id`), + KEY `idx_scim_user_emails_email_type` (`type`,`email`), + KEY `fk_scim_user_emails_scim_user_id` (`scim_user_id`), + CONSTRAINT `fk_scim_user_emails_scim_user_id` FOREIGN KEY (`scim_user_id`) REFERENCES `scim_users` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `scim_user_group` ( + `scim_user_id` int unsigned NOT NULL, + `group_id` int unsigned NOT NULL, + `created_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + PRIMARY KEY (`scim_user_id`,`group_id`), + KEY `fk_scim_user_group_group_id` (`group_id`), + CONSTRAINT `fk_scim_user_group_group_id` FOREIGN KEY (`group_id`) REFERENCES `scim_groups` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_scim_user_group_scim_user_id` FOREIGN KEY (`scim_user_id`) REFERENCES `scim_users` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `scim_users` ( + `id` int unsigned NOT NULL AUTO_INCREMENT, + `external_id` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `user_name` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL, + `given_name` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `family_name` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `active` tinyint(1) DEFAULT NULL, + `created_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + `updated_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), + PRIMARY KEY (`id`), + UNIQUE KEY `idx_scim_users_user_name` (`user_name`), + KEY `idx_scim_users_external_id` (`external_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; CREATE TABLE `script_contents` ( `id` int unsigned NOT NULL AUTO_INCREMENT, `md5_checksum` binary(16) NOT NULL, diff --git a/server/datastore/mysql/scim.go b/server/datastore/mysql/scim.go new file mode 100644 index 0000000000..8b29e01164 --- /dev/null +++ b/server/datastore/mysql/scim.go @@ -0,0 +1,331 @@ +package mysql + +import ( + "context" + "database/sql" + "errors" + "strings" + + "github.com/fleetdm/fleet/v4/server/contexts/ctxerr" + "github.com/fleetdm/fleet/v4/server/fleet" + "github.com/jmoiron/sqlx" +) + +// CreateScimUser creates a new SCIM user in the database +func (ds *Datastore) CreateScimUser(ctx context.Context, user *fleet.ScimUser) (uint, error) { + var userID uint + err := ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error { + const insertUserQuery = ` + INSERT INTO scim_users ( + external_id, user_name, given_name, family_name, active + ) VALUES (?, ?, ?, ?, ?)` + result, err := tx.ExecContext( + ctx, + insertUserQuery, + user.ExternalID, + user.UserName, + user.GivenName, + user.FamilyName, + user.Active, + ) + if err != nil { + return ctxerr.Wrap(ctx, err, "insert scim user") + } + + id, err := result.LastInsertId() + if err != nil { + return ctxerr.Wrap(ctx, err, "insert scim user last insert id") + } + user.ID = uint(id) // nolint:gosec // dismiss G115 + userID = user.ID + + return insertEmails(ctx, tx, user) + }) + return userID, err +} + +// ScimUserByID retrieves a SCIM user by ID +func (ds *Datastore) ScimUserByID(ctx context.Context, id uint) (*fleet.ScimUser, error) { + const query = ` + SELECT + id, external_id, user_name, given_name, family_name, active + FROM scim_users + WHERE id = ? + ` + user := &fleet.ScimUser{} + err := sqlx.GetContext(ctx, ds.reader(ctx), user, query, id) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, notFound("scim user").WithID(id) + } + return nil, ctxerr.Wrap(ctx, err, "select scim user") + } + + // Get the user's emails + emails, err := ds.getScimUserEmails(ctx, id) + if err != nil { + return nil, err + } + user.Emails = emails + + return user, nil +} + +// ScimUserByUserName retrieves a SCIM user by username +func (ds *Datastore) ScimUserByUserName(ctx context.Context, userName string) (*fleet.ScimUser, error) { + const query = ` + SELECT + id, external_id, user_name, given_name, family_name, active + FROM scim_users + WHERE user_name = ? + ` + user := &fleet.ScimUser{} + err := sqlx.GetContext(ctx, ds.reader(ctx), user, query, userName) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, notFound("scim user") + } + return nil, ctxerr.Wrap(ctx, err, "select scim user by userName") + } + + // Get the user's emails + emails, err := ds.getScimUserEmails(ctx, user.ID) + if err != nil { + return nil, err + } + user.Emails = emails + + return user, nil +} + +// ReplaceScimUser replaces an existing SCIM user in the database +func (ds *Datastore) ReplaceScimUser(ctx context.Context, user *fleet.ScimUser) error { + return ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error { + // Update the SCIM user + const updateUserQuery = ` + UPDATE scim_users SET + external_id = ?, + user_name = ?, + given_name = ?, + family_name = ?, + active = ? + WHERE id = ?` + result, err := tx.ExecContext( + ctx, + updateUserQuery, + user.ExternalID, + user.UserName, + user.GivenName, + user.FamilyName, + user.Active, + user.ID, + ) + if err != nil { + return ctxerr.Wrap(ctx, err, "update scim user") + } + + rowsAffected, err := result.RowsAffected() + if err != nil { + return ctxerr.Wrap(ctx, err, "get rows affected for update scim user") + } + if rowsAffected == 0 { + return notFound("scim user").WithID(user.ID) + } + + // We assume that email is not blank/null. + // However, we do not assume that email/type are unique for a user. To keep the code simple, we: + // 1. Delete all existing emails + // 2. Insert all new emails + // This is less efficient and can be optimized if we notice a load on these tables in production. + + const deleteEmailsQuery = `DELETE FROM scim_user_emails WHERE scim_user_id = ?` + _, err = tx.ExecContext(ctx, deleteEmailsQuery, user.ID) + if err != nil { + return ctxerr.Wrap(ctx, err, "delete scim user emails") + } + + return insertEmails(ctx, tx, user) + }) +} + +func insertEmails(ctx context.Context, tx sqlx.ExtContext, user *fleet.ScimUser) error { + // Insert the user's emails in a single batch if any + if len(user.Emails) > 0 { + // Build the batch insert query + valueStrings := make([]string, 0, len(user.Emails)) + valueArgs := make([]interface{}, 0, len(user.Emails)*4) + + for i := range user.Emails { + user.Emails[i].ScimUserID = user.ID + valueStrings = append(valueStrings, "(?, ?, ?, ?)") + valueArgs = append(valueArgs, + user.Emails[i].ScimUserID, + user.Emails[i].Email, + user.Emails[i].Primary, + user.Emails[i].Type, + ) + } + + // Construct the batch insert query + insertEmailQuery := ` + INSERT INTO scim_user_emails ( + scim_user_id, email, ` + "`primary`" + `, type + ) VALUES ` + strings.Join(valueStrings, ",") + + // Execute the batch insert + _, err := tx.ExecContext(ctx, insertEmailQuery, valueArgs...) + if err != nil { + return ctxerr.Wrap(ctx, err, "batch insert scim user emails") + } + } + return nil +} + +// DeleteScimUser deletes a SCIM user from the database +func (ds *Datastore) DeleteScimUser(ctx context.Context, id uint) error { + return ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error { + // Delete all email entries for the user + const deleteEmailsQuery = `DELETE FROM scim_user_emails WHERE scim_user_id = ?` + _, err := tx.ExecContext(ctx, deleteEmailsQuery, id) + if err != nil { + return ctxerr.Wrap(ctx, err, "delete scim user emails") + } + + // Delete the user + const deleteUserQuery = `DELETE FROM scim_users WHERE id = ?` + result, err := tx.ExecContext(ctx, deleteUserQuery, id) + if err != nil { + return ctxerr.Wrap(ctx, err, "delete scim user") + } + + // Check if the user existed + rowsAffected, err := result.RowsAffected() + if err != nil { + return ctxerr.Wrap(ctx, err, "get rows affected for delete scim user") + } + if rowsAffected == 0 { + return notFound("scim user").WithID(id) + } + + return nil + }) +} + +// ListScimUsers retrieves a list of SCIM users with optional filtering +func (ds *Datastore) ListScimUsers(ctx context.Context, opts fleet.ScimUsersListOptions) (users []fleet.ScimUser, totalResults uint, err error) { + // Default pagination values if not provided + if opts.Page == 0 { + opts.Page = 1 + } + if opts.PerPage == 0 { + opts.PerPage = 100 + } + + // Calculate offset for pagination + offset := (opts.Page - 1) * opts.PerPage + + // Build the base query + baseQuery := ` + SELECT DISTINCT + scim_users.id, external_id, user_name, given_name, family_name, active + FROM scim_users + ` + + // Add joins and where clauses based on filters + var whereClause string + var params []interface{} + + if opts.UserNameFilter != nil { + // Filter by username + whereClause = " WHERE scim_users.user_name = ?" + params = append(params, *opts.UserNameFilter) + } else if opts.EmailTypeFilter != nil && opts.EmailValueFilter != nil { + // Filter by email type and value + baseQuery += " LEFT JOIN scim_user_emails ON scim_users.id = scim_user_emails.scim_user_id" + whereClause = " WHERE scim_user_emails.type = ? AND scim_user_emails.email = ?" + params = append(params, *opts.EmailTypeFilter, *opts.EmailValueFilter) + } + + // First, get the total count without pagination + countQuery := "SELECT COUNT(DISTINCT id) FROM (" + baseQuery + whereClause + ") AS filtered_users" + err = sqlx.GetContext(ctx, ds.reader(ctx), &totalResults, countQuery, params...) + if err != nil { + return nil, 0, ctxerr.Wrap(ctx, err, "count total scim users") + } + + // Add pagination to the main query + query := baseQuery + whereClause + " ORDER BY scim_users.id LIMIT ? OFFSET ?" + params = append(params, opts.PerPage, offset) + + // Execute the query + err = sqlx.SelectContext(ctx, ds.reader(ctx), &users, query, params...) + if err != nil { + return nil, 0, ctxerr.Wrap(ctx, err, "list scim users") + } + + // Process the results + userIDs := make([]uint, 0, len(users)) + userMap := make(map[uint]*fleet.ScimUser, len(users)) + + for i, user := range users { + userIDs = append(userIDs, user.ID) + userMap[user.ID] = &users[i] + } + + // If no users found, return empty slice + if len(users) == 0 { + return users, totalResults, nil + } + + // Fetch emails for all users in a single query + emailQuery, args, err := sqlx.In(` + SELECT + scim_user_id, email, `+"`primary`"+`, type + FROM scim_user_emails + WHERE scim_user_id IN (?) + ORDER BY email ASC + `, userIDs) + if err != nil { + return nil, 0, ctxerr.Wrap(ctx, err, "prepare emails query") + } + + // Convert query for the specific DB dialect + emailQuery = ds.reader(ctx).Rebind(emailQuery) + + // Execute the email query + var allEmails []fleet.ScimUserEmail + if err := sqlx.SelectContext(ctx, ds.reader(ctx), &allEmails, emailQuery, args...); err != nil { + if !errors.Is(err, sql.ErrNoRows) { + return nil, 0, ctxerr.Wrap(ctx, err, "select scim user emails") + } + } + + // Associate emails with their users + for i := range allEmails { + email := allEmails[i] + if user, ok := userMap[email.ScimUserID]; ok { + user.Emails = append(user.Emails, email) + } + } + + return users, totalResults, nil +} + +// getScimUserEmails retrieves all emails for a SCIM user +func (ds *Datastore) getScimUserEmails(ctx context.Context, userID uint) ([]fleet.ScimUserEmail, error) { + const query = ` + SELECT + scim_user_id, email, ` + "`primary`" + `, type + FROM scim_user_emails + WHERE scim_user_id = ? ORDER BY email ASC + ` + var emails []fleet.ScimUserEmail + err := sqlx.SelectContext(ctx, ds.reader(ctx), &emails, query, userID) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + return nil, ctxerr.Wrap(ctx, err, "select scim user emails") + } + return emails, nil +} diff --git a/server/datastore/mysql/scim_test.go b/server/datastore/mysql/scim_test.go new file mode 100644 index 0000000000..e07eca156c --- /dev/null +++ b/server/datastore/mysql/scim_test.go @@ -0,0 +1,493 @@ +package mysql + +import ( + "context" + "testing" + + "github.com/fleetdm/fleet/v4/server/fleet" + "github.com/fleetdm/fleet/v4/server/ptr" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestScim(t *testing.T) { + ds := CreateMySQLDS(t) + + cases := []struct { + name string + fn func(t *testing.T, ds *Datastore) + }{ + {"ScimUserCreate", testScimUserCreate}, + {"ScimUserByID", testScimUserByID}, + {"ScimUserByUserName", testScimUserByUserName}, + {"ReplaceScimUser", testReplaceScimUser}, + {"DeleteScimUser", testDeleteScimUser}, + {"ListScimUsers", testListScimUsers}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + defer TruncateTables(t, ds, "scim_users", "scim_user_emails") + c.fn(t, ds) + }) + } +} + +func testScimUserCreate(t *testing.T, ds *Datastore) { + usersToCreate := []fleet.ScimUser{ + { + UserName: "user1", + ExternalID: nil, + GivenName: nil, + FamilyName: nil, + Active: nil, + Emails: []fleet.ScimUserEmail{}, + }, + { + UserName: "user2", + ExternalID: ptr.String("ext-123"), + GivenName: ptr.String("John"), + FamilyName: ptr.String("Doe"), + Active: ptr.Bool(true), + Emails: []fleet.ScimUserEmail{ + { + Email: "john.doe@example.com", + Primary: ptr.Bool(true), + Type: ptr.String("work"), + }, + }, + }, + { + UserName: "user3", + ExternalID: ptr.String("ext-456"), + GivenName: ptr.String("Jane"), + FamilyName: ptr.String("Smith"), + Active: ptr.Bool(true), + Emails: []fleet.ScimUserEmail{ + { + Email: "jane.personal@example.com", + Primary: ptr.Bool(false), + Type: ptr.String("home"), + }, + { + Email: "jane.smith@example.com", + Primary: ptr.Bool(true), + Type: ptr.String("work"), + }, + }, + }, + } + + for _, u := range usersToCreate { + var err error + userCopy := u + userCopy.ID, err = ds.CreateScimUser(context.Background(), &u) + assert.Nil(t, err) + + verify, err := ds.ScimUserByUserName(context.Background(), u.UserName) + assert.Nil(t, err) + + assert.Equal(t, userCopy.ID, verify.ID) + assert.Equal(t, userCopy.UserName, verify.UserName) + assert.Equal(t, userCopy.ExternalID, verify.ExternalID) + assert.Equal(t, userCopy.GivenName, verify.GivenName) + assert.Equal(t, userCopy.FamilyName, verify.FamilyName) + assert.Equal(t, userCopy.Active, verify.Active) + + // Verify emails + assert.Equal(t, len(userCopy.Emails), len(verify.Emails)) + for i, email := range userCopy.Emails { + assert.Equal(t, email.Email, verify.Emails[i].Email) + assert.Equal(t, email.Primary, verify.Emails[i].Primary) + assert.Equal(t, email.Type, verify.Emails[i].Type) + assert.Equal(t, u.ID, verify.Emails[i].ScimUserID) + } + } +} + +func testScimUserByID(t *testing.T, ds *Datastore) { + users := createTestScimUsers(t, ds) + for _, tt := range users { + returned, err := ds.ScimUserByID(context.Background(), tt.ID) + assert.Nil(t, err) + assert.Equal(t, tt.ID, returned.ID) + assert.Equal(t, tt.UserName, returned.UserName) + assert.Equal(t, tt.ExternalID, returned.ExternalID) + assert.Equal(t, tt.GivenName, returned.GivenName) + assert.Equal(t, tt.FamilyName, returned.FamilyName) + assert.Equal(t, tt.Active, returned.Active) + + // Verify emails + assert.Equal(t, len(tt.Emails), len(returned.Emails)) + for i, email := range tt.Emails { + assert.Equal(t, email.Email, returned.Emails[i].Email) + assert.Equal(t, email.Primary, returned.Emails[i].Primary) + assert.Equal(t, email.Type, returned.Emails[i].Type) + assert.Equal(t, tt.ID, returned.Emails[i].ScimUserID) + } + } + + // test missing user + _, err := ds.ScimUserByID(context.Background(), 10000000000) + assert.True(t, fleet.IsNotFound(err)) +} + +func testScimUserByUserName(t *testing.T, ds *Datastore) { + users := createTestScimUsers(t, ds) + for _, tt := range users { + returned, err := ds.ScimUserByUserName(context.Background(), tt.UserName) + assert.Nil(t, err) + assert.Equal(t, tt.ID, returned.ID) + assert.Equal(t, tt.UserName, returned.UserName) + assert.Equal(t, tt.ExternalID, returned.ExternalID) + assert.Equal(t, tt.GivenName, returned.GivenName) + assert.Equal(t, tt.FamilyName, returned.FamilyName) + assert.Equal(t, tt.Active, returned.Active) + + // Verify emails + assert.Equal(t, len(tt.Emails), len(returned.Emails)) + for i, email := range tt.Emails { + assert.Equal(t, email.Email, returned.Emails[i].Email) + assert.Equal(t, email.Primary, returned.Emails[i].Primary) + assert.Equal(t, email.Type, returned.Emails[i].Type) + assert.Equal(t, tt.ID, returned.Emails[i].ScimUserID) + } + } + + // test missing user + _, err := ds.ScimUserByUserName(context.Background(), "nonexistent-user") + assert.NotNil(t, err) +} + +func createTestScimUsers(t *testing.T, ds *Datastore) []*fleet.ScimUser { + createUsers := []fleet.ScimUser{ + { + UserName: "test-user1", + ExternalID: ptr.String("ext-test-123"), + GivenName: ptr.String("Test"), + FamilyName: ptr.String("User"), + Active: ptr.Bool(true), + Emails: []fleet.ScimUserEmail{ + { + Email: "test.user@example.com", + Primary: ptr.Bool(true), + Type: ptr.String("work"), + }, + }, + }, + { + UserName: "test-user2", + ExternalID: ptr.String("ext-test-456"), + GivenName: ptr.String("Another"), + FamilyName: ptr.String("User"), + Active: ptr.Bool(true), + Emails: []fleet.ScimUserEmail{ + { + Email: "another.personal@example.com", + Primary: ptr.Bool(false), + Type: ptr.String("home"), + }, + { + Email: "another.user@example.com", + Primary: ptr.Bool(true), + Type: ptr.String("work"), + }, + }, + }, + } + + var users []*fleet.ScimUser + for _, u := range createUsers { + var err error + u.ID, err = ds.CreateScimUser(context.Background(), &u) + require.Nil(t, err) + users = append(users, &u) + } + return users +} + +func testReplaceScimUser(t *testing.T, ds *Datastore) { + // Create a test user + user := fleet.ScimUser{ + UserName: "replace-test-user", + ExternalID: ptr.String("ext-replace-123"), + GivenName: ptr.String("Original"), + FamilyName: ptr.String("User"), + Active: ptr.Bool(true), + Emails: []fleet.ScimUserEmail{ + { + Email: "original.user@example.com", + Primary: ptr.Bool(true), + Type: ptr.String("work"), + }, + }, + } + + var err error + user.ID, err = ds.CreateScimUser(context.Background(), &user) + require.Nil(t, err) + + // Verify the user was created correctly + createdUser, err := ds.ScimUserByID(context.Background(), user.ID) + require.Nil(t, err) + assert.Equal(t, user.UserName, createdUser.UserName) + assert.Equal(t, user.ExternalID, createdUser.ExternalID) + assert.Equal(t, user.GivenName, createdUser.GivenName) + assert.Equal(t, user.FamilyName, createdUser.FamilyName) + assert.Equal(t, user.Active, createdUser.Active) + assert.Equal(t, 1, len(createdUser.Emails)) + assert.Equal(t, "original.user@example.com", createdUser.Emails[0].Email) + + // Modify the user + updatedUser := fleet.ScimUser{ + ID: user.ID, + UserName: "replace-test-user", // Same username + ExternalID: ptr.String("ext-replace-456"), // Changed external ID + GivenName: ptr.String("Updated"), // Changed given name + FamilyName: ptr.String("User"), // Same family name + Active: ptr.Bool(false), // Changed active status + Emails: []fleet.ScimUserEmail{ // Changed emails + { + Email: "updated.user@example.com", + Primary: ptr.Bool(true), + Type: ptr.String("work"), + }, + { + Email: "personal.user@example.com", + Primary: ptr.Bool(false), + Type: ptr.String("home"), + }, + }, + } + + // Replace the user + err = ds.ReplaceScimUser(context.Background(), &updatedUser) + require.Nil(t, err) + + // Verify the user was updated correctly + replacedUser, err := ds.ScimUserByID(context.Background(), user.ID) + require.Nil(t, err) + assert.Equal(t, updatedUser.UserName, replacedUser.UserName) + assert.Equal(t, updatedUser.ExternalID, replacedUser.ExternalID) + assert.Equal(t, updatedUser.GivenName, replacedUser.GivenName) + assert.Equal(t, updatedUser.FamilyName, replacedUser.FamilyName) + assert.Equal(t, updatedUser.Active, replacedUser.Active) + + // Verify emails were replaced + assert.Equal(t, 2, len(replacedUser.Emails)) + assert.Equal(t, "personal.user@example.com", replacedUser.Emails[0].Email) // Alphabetical order + assert.Equal(t, "updated.user@example.com", replacedUser.Emails[1].Email) + + // Test replacing a non-existent user + nonExistentUser := fleet.ScimUser{ + ID: 99999, // Non-existent ID + UserName: "non-existent", + ExternalID: ptr.String("ext-non-existent"), + GivenName: ptr.String("Non"), + FamilyName: ptr.String("Existent"), + Active: ptr.Bool(true), + } + + err = ds.ReplaceScimUser(context.Background(), &nonExistentUser) + assert.True(t, fleet.IsNotFound(err)) +} + +func testDeleteScimUser(t *testing.T, ds *Datastore) { + // Create a test user + user := fleet.ScimUser{ + UserName: "delete-test-user", + ExternalID: ptr.String("ext-delete-123"), + GivenName: ptr.String("Delete"), + FamilyName: ptr.String("User"), + Active: ptr.Bool(true), + Emails: []fleet.ScimUserEmail{ + { + Email: "delete.user@example.com", + Primary: ptr.Bool(true), + Type: ptr.String("work"), + }, + }, + } + + var err error + user.ID, err = ds.CreateScimUser(context.Background(), &user) + require.Nil(t, err) + + // Verify the user was created correctly + createdUser, err := ds.ScimUserByID(context.Background(), user.ID) + require.Nil(t, err) + assert.Equal(t, user.UserName, createdUser.UserName) + + // Delete the user + err = ds.DeleteScimUser(context.Background(), user.ID) + require.Nil(t, err) + + // Verify the user was deleted + _, err = ds.ScimUserByID(context.Background(), user.ID) + assert.True(t, fleet.IsNotFound(err)) + + // Test deleting a non-existent user + err = ds.DeleteScimUser(context.Background(), 99999) // Non-existent ID + assert.True(t, fleet.IsNotFound(err)) +} + +func testListScimUsers(t *testing.T, ds *Datastore) { + // Create test users with different attributes and emails + users := []fleet.ScimUser{ + { + UserName: "list-test-user1", + ExternalID: ptr.String("ext-list-123"), + GivenName: ptr.String("List"), + FamilyName: ptr.String("User1"), + Active: ptr.Bool(true), + Emails: []fleet.ScimUserEmail{ + { + Email: "list.user1@example.com", + Primary: ptr.Bool(true), + Type: ptr.String("work"), + }, + }, + }, + { + UserName: "list-test-user2", + ExternalID: ptr.String("ext-list-456"), + GivenName: ptr.String("List"), + FamilyName: ptr.String("User2"), + Active: ptr.Bool(true), + Emails: []fleet.ScimUserEmail{ + { + Email: "list.user2@example.com", + Primary: ptr.Bool(true), + Type: ptr.String("work"), + }, + { + Email: "personal.user2@example.com", + Primary: ptr.Bool(false), + Type: ptr.String("home"), + }, + }, + }, + { + UserName: "different-user3", + ExternalID: ptr.String("ext-list-789"), + GivenName: ptr.String("Different"), + FamilyName: ptr.String("User3"), + Active: ptr.Bool(false), + Emails: []fleet.ScimUserEmail{ + { + Email: "different.user3@example.com", + Primary: ptr.Bool(true), + Type: ptr.String("work"), + }, + }, + }, + } + + // Create the users + for i := range users { + var err error + users[i].ID, err = ds.CreateScimUser(context.Background(), &users[i]) + require.Nil(t, err) + } + + // Test 1: List all users without filters + allUsers, totalResults, err := ds.ListScimUsers(context.Background(), fleet.ScimUsersListOptions{ + Page: 1, + PerPage: 10, + }) + require.Nil(t, err) + assert.Equal(t, 3, len(allUsers)) + assert.Equal(t, uint(3), totalResults) + + // Verify that our test users are in the results + foundUsers := 0 + for _, u := range allUsers { + for _, testUser := range users { + if u.ID == testUser.ID { + foundUsers++ + break + } + } + } + assert.Equal(t, 3, foundUsers) + + // Test 2: Pagination - first page with 2 items + page1Users, totalPage1, err := ds.ListScimUsers(context.Background(), fleet.ScimUsersListOptions{ + Page: 1, + PerPage: 2, + }) + require.Nil(t, err) + assert.Equal(t, 2, len(page1Users)) + assert.Equal(t, uint(3), totalPage1) // Total should still be 3 + + // Test 3: Pagination - second page with 2 items + page2Users, totalPage2, err := ds.ListScimUsers(context.Background(), fleet.ScimUsersListOptions{ + Page: 2, + PerPage: 2, + }) + require.Nil(t, err) + assert.Equal(t, 1, len(page2Users)) + assert.Equal(t, uint(3), totalPage2) // Total should still be 3 + + // Verify that page1 and page2 contain different users + for _, p1User := range page1Users { + for _, p2User := range page2Users { + assert.NotEqual(t, p1User.ID, p2User.ID, "Users should not appear on multiple pages") + } + } + + // Test 4: Filter by username + listUsers, totalListUsers, err := ds.ListScimUsers(context.Background(), fleet.ScimUsersListOptions{ + Page: 1, + PerPage: 10, + UserNameFilter: ptr.String("list-test-user2"), + }) + + require.Nil(t, err) + require.Len(t, listUsers, 1) + assert.Equal(t, uint(1), totalListUsers) + assert.Equal(t, "list-test-user2", listUsers[0].UserName) + + // Test 5: Filter by email type and value + homeEmailUsers, totalHomeEmailUsers, err := ds.ListScimUsers(context.Background(), fleet.ScimUsersListOptions{ + Page: 1, + PerPage: 10, + EmailTypeFilter: ptr.String("home"), + EmailValueFilter: ptr.String("personal.user2@example.com"), + }) + require.Nil(t, err) + require.Len(t, homeEmailUsers, 1) + assert.Equal(t, uint(1), totalHomeEmailUsers) + assert.Equal(t, users[1].ID, homeEmailUsers[0].ID) + assert.Equal(t, 2, len(homeEmailUsers[0].Emails)) + + // Test 6: Filter by email type and value - work emails + workEmailUsers, totalWorkEmailUsers, err := ds.ListScimUsers(context.Background(), fleet.ScimUsersListOptions{ + Page: 1, + PerPage: 10, + EmailTypeFilter: ptr.String("work"), + EmailValueFilter: ptr.String("different.user3@example.com"), + }) + require.Nil(t, err) + assert.Len(t, workEmailUsers, 1) + assert.Equal(t, uint(1), totalWorkEmailUsers) + + // Test 7: No results for non-matching filters + noUsers, totalNoUsers1, err := ds.ListScimUsers(context.Background(), fleet.ScimUsersListOptions{ + Page: 1, + PerPage: 10, + UserNameFilter: ptr.String("nonexistent"), + }) + require.Nil(t, err) + assert.Empty(t, noUsers) + assert.Equal(t, uint(0), totalNoUsers1) + + noUsers, totalNoUsers2, err := ds.ListScimUsers(context.Background(), fleet.ScimUsersListOptions{ + Page: 1, + PerPage: 10, + EmailTypeFilter: ptr.String("nonexistent"), + EmailValueFilter: ptr.String("nonexistent"), + }) + require.Nil(t, err) + assert.Empty(t, noUsers) + assert.Equal(t, uint(0), totalNoUsers2) +} diff --git a/server/fleet/datastore.go b/server/fleet/datastore.go index 0bfd0f2131..77a01eb50f 100644 --- a/server/fleet/datastore.go +++ b/server/fleet/datastore.go @@ -2012,6 +2012,22 @@ type Datastore interface { // Android AndroidDatastore + + // ///////////////////////////////////////////////////////////////////////////// + // SCIM + + // CreateScimUser creates a new SCIM user in the database + CreateScimUser(ctx context.Context, user *ScimUser) (uint, error) + // ScimUserByID retrieves a SCIM user by ID + ScimUserByID(ctx context.Context, id uint) (*ScimUser, error) + // ScimUserByUserName retrieves a SCIM user by username + ScimUserByUserName(ctx context.Context, userName string) (*ScimUser, error) + // ReplaceScimUser replaces an existing SCIM user in the database + ReplaceScimUser(ctx context.Context, user *ScimUser) error + // DeleteScimUser deletes a SCIM user from the database + DeleteScimUser(ctx context.Context, id uint) error + // ListScimUsers retrieves a list of SCIM users with optional filtering + ListScimUsers(ctx context.Context, opts ScimUsersListOptions) (users []ScimUser, totalResults uint, err error) } type AndroidDatastore interface { diff --git a/server/fleet/scim.go b/server/fleet/scim.go new file mode 100644 index 0000000000..d9c6497553 --- /dev/null +++ b/server/fleet/scim.go @@ -0,0 +1,41 @@ +package fleet + +// ScimUser represents a SCIM user in the database +type ScimUser struct { + ID uint `db:"id"` + ExternalID *string `db:"external_id"` + UserName string `db:"user_name"` + GivenName *string `db:"given_name"` + FamilyName *string `db:"family_name"` + Active *bool `db:"active"` + Emails []ScimUserEmail +} + +func (su *ScimUser) AuthzType() string { + return "scim_user" +} + +// ScimUserEmail represents an email address associated with a SCIM user +type ScimUserEmail struct { + ScimUserID uint `db:"scim_user_id"` + Email string `db:"email"` + Primary *bool `db:"primary"` + Type *string `db:"type"` +} + +type ScimUsersListOptions struct { + // Which page to return (must be positive integer) + Page uint + // How many results per page (must be positive integer) + PerPage uint + + // UserNameFilter filters by userName -- max of 1 response is expected + // Cannot be used with other filters. + UserNameFilter *string + + // EmailTypeFilter and EmailValueFilter are needed to support Entra ID filter: emails[type eq "work"].value eq "user@contoso.com" + // https://learn.microsoft.com/en-us/entra/identity/app-provisioning/use-scim-to-provision-users-and-groups#users + // Cannot be used with other filters. + EmailTypeFilter *string + EmailValueFilter *string +} diff --git a/server/mock/datastore_mock.go b/server/mock/datastore_mock.go index 9f66733605..543abddd85 100644 --- a/server/mock/datastore_mock.go +++ b/server/mock/datastore_mock.go @@ -1284,6 +1284,18 @@ type SetAndroidEnabledAndConfiguredFunc func(ctx context.Context, configured boo type UpdateAndroidHostFunc func(ctx context.Context, host *fleet.AndroidHost, fromEnroll bool) error +type CreateScimUserFunc func(ctx context.Context, user *fleet.ScimUser) (uint, error) + +type ScimUserByIDFunc func(ctx context.Context, id uint) (*fleet.ScimUser, error) + +type ScimUserByUserNameFunc func(ctx context.Context, userName string) (*fleet.ScimUser, error) + +type ReplaceScimUserFunc func(ctx context.Context, user *fleet.ScimUser) error + +type DeleteScimUserFunc func(ctx context.Context, id uint) error + +type ListScimUsersFunc func(ctx context.Context, opts fleet.ScimUsersListOptions) (users []fleet.ScimUser, totalResults uint, err error) + type DataStore struct { HealthCheckFunc HealthCheckFunc HealthCheckFuncInvoked bool @@ -3178,6 +3190,24 @@ type DataStore struct { UpdateAndroidHostFunc UpdateAndroidHostFunc UpdateAndroidHostFuncInvoked bool + CreateScimUserFunc CreateScimUserFunc + CreateScimUserFuncInvoked bool + + ScimUserByIDFunc ScimUserByIDFunc + ScimUserByIDFuncInvoked bool + + ScimUserByUserNameFunc ScimUserByUserNameFunc + ScimUserByUserNameFuncInvoked bool + + ReplaceScimUserFunc ReplaceScimUserFunc + ReplaceScimUserFuncInvoked bool + + DeleteScimUserFunc DeleteScimUserFunc + DeleteScimUserFuncInvoked bool + + ListScimUsersFunc ListScimUsersFunc + ListScimUsersFuncInvoked bool + mu sync.Mutex } @@ -7597,3 +7627,45 @@ func (s *DataStore) UpdateAndroidHost(ctx context.Context, host *fleet.AndroidHo s.mu.Unlock() return s.UpdateAndroidHostFunc(ctx, host, fromEnroll) } + +func (s *DataStore) CreateScimUser(ctx context.Context, user *fleet.ScimUser) (uint, error) { + s.mu.Lock() + s.CreateScimUserFuncInvoked = true + s.mu.Unlock() + return s.CreateScimUserFunc(ctx, user) +} + +func (s *DataStore) ScimUserByID(ctx context.Context, id uint) (*fleet.ScimUser, error) { + s.mu.Lock() + s.ScimUserByIDFuncInvoked = true + s.mu.Unlock() + return s.ScimUserByIDFunc(ctx, id) +} + +func (s *DataStore) ScimUserByUserName(ctx context.Context, userName string) (*fleet.ScimUser, error) { + s.mu.Lock() + s.ScimUserByUserNameFuncInvoked = true + s.mu.Unlock() + return s.ScimUserByUserNameFunc(ctx, userName) +} + +func (s *DataStore) ReplaceScimUser(ctx context.Context, user *fleet.ScimUser) error { + s.mu.Lock() + s.ReplaceScimUserFuncInvoked = true + s.mu.Unlock() + return s.ReplaceScimUserFunc(ctx, user) +} + +func (s *DataStore) DeleteScimUser(ctx context.Context, id uint) error { + s.mu.Lock() + s.DeleteScimUserFuncInvoked = true + s.mu.Unlock() + return s.DeleteScimUserFunc(ctx, id) +} + +func (s *DataStore) ListScimUsers(ctx context.Context, opts fleet.ScimUsersListOptions) (users []fleet.ScimUser, totalResults uint, err error) { + s.mu.Lock() + s.ListScimUsersFuncInvoked = true + s.mu.Unlock() + return s.ListScimUsersFunc(ctx, opts) +} diff --git a/server/service/middleware/auth/auth.go b/server/service/middleware/auth/auth.go index 8cc33df35a..1a63d3d3db 100644 --- a/server/service/middleware/auth/auth.go +++ b/server/service/middleware/auth/auth.go @@ -2,6 +2,7 @@ package auth import ( "context" + "net/http" "github.com/fleetdm/fleet/v4/server/contexts/authz" "github.com/fleetdm/fleet/v4/server/contexts/token" @@ -39,7 +40,7 @@ func AuthenticatedUser(svc fleet.Service, next endpoint.Endpoint) endpoint.Endpo return next(ctx, request) } - // if not succesful, try again this time with errors + // if not successful, try again this time with errors sessionKey, ok := token.FromContext(ctx) if !ok { return nil, fleet.NewAuthHeaderRequiredError("no auth token") @@ -67,3 +68,44 @@ func AuthenticatedUser(svc fleet.Service, next endpoint.Endpoint) endpoint.Endpo func UnauthenticatedRequest(_ fleet.Service, next endpoint.Endpoint) endpoint.Endpoint { return log.Logged(next) } + +// errorHandler has the same signature as http.Error +type errorHandler func(w http.ResponseWriter, detail string, status int) + +func AuthenticatedUserMiddleware(svc fleet.Service, errHandler errorHandler, next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // first check if already successfully set + if v, ok := viewer.FromContext(r.Context()); ok { + if v.User.IsAdminForcedPasswordReset() { + errHandler(w, fleet.ErrPasswordResetRequired.Error(), http.StatusUnauthorized) + return + } + next.ServeHTTP(w, r) + return + } + + // if not successful, try again this time with errors + sessionKey, ok := token.FromContext(r.Context()) + if !ok { + errHandler(w, fleet.NewAuthHeaderRequiredError("no auth token").Error(), http.StatusUnauthorized) + return + } + + v, err := AuthViewer(r.Context(), string(sessionKey), svc) + if err != nil { + errHandler(w, err.Error(), http.StatusUnauthorized) + return + } + + if v.User.IsAdminForcedPasswordReset() { + errHandler(w, fleet.ErrPasswordResetRequired.Error(), http.StatusUnauthorized) + return + } + + ctx := viewer.NewContext(r.Context(), *v) + if ac, ok := authz.FromContext(r.Context()); ok { + ac.SetAuthnMethod(authz.AuthnUserToken) + } + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} diff --git a/server/service/middleware/auth/http_auth.go b/server/service/middleware/auth/http_auth.go index 568f434632..1034bc8ec1 100644 --- a/server/service/middleware/auth/http_auth.go +++ b/server/service/middleware/auth/http_auth.go @@ -28,3 +28,11 @@ func SetRequestsContexts(svc fleet.Service) kithttp.RequestFunc { return ctx } } + +func SetRequestsContextMiddleware(svc fleet.Service, next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctx := kithttp.PopulateRequestContext(r.Context(), r) + ctx = SetRequestsContexts(svc)(ctx, r) + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} diff --git a/server/service/middleware/log/log.go b/server/service/middleware/log/log.go index 007e2fbb1c..b5b88b9ce9 100644 --- a/server/service/middleware/log/log.go +++ b/server/service/middleware/log/log.go @@ -37,3 +37,10 @@ func LogRequestEnd(logger kitlog.Logger) func(context.Context, http.ResponseWrit return ctx } } + +func LogResponseEndMiddleware(logger kitlog.Logger, next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + next.ServeHTTP(w, r) + LogRequestEnd(logger)(r.Context(), w) + }) +}