From a7ff10bb34d823462a5a503bde9817a72b54ad99 Mon Sep 17 00:00:00 2001 From: Juan Fernandez Date: Sat, 11 Apr 2026 07:43:01 -0400 Subject: [PATCH 01/18] Allow the creation of API-only users - Added POST /users/api_only endpoint for creating API-only users. - Added PATCH /users/api_only/{id} for updating existing API-only users. - Updated `fleetctl user create --api-only` removing email/password field requirements. --- ...880-42884-allow-creation-of-api-only-users | 3 + cmd/fleetctl/fleetctl/user.go | 32 +- cmd/fleetctl/fleetctl/users_test.go | 33 +- ...000000_DropAuthorIdFromUserApiEndpoints.go | 41 ++ server/datastore/mysql/users.go | 90 ++++- server/fleet/service.go | 4 + server/fleet/users.go | 17 + server/mock/service/service_mock.go | 12 + server/service/client_users.go | 21 + server/service/handler.go | 2 + server/service/integration_core_test.go | 102 +++++ server/service/integration_enterprise_test.go | 358 ++++++++++++++++++ server/service/users.go | 257 ++++++++++++- server/utils.go | 35 ++ server/utils_test.go | 38 ++ 15 files changed, 1014 insertions(+), 31 deletions(-) create mode 100644 changes/42882-42880-42884-allow-creation-of-api-only-users create mode 100644 server/datastore/mysql/migrations/tables/20260411000000_DropAuthorIdFromUserApiEndpoints.go diff --git a/changes/42882-42880-42884-allow-creation-of-api-only-users b/changes/42882-42880-42884-allow-creation-of-api-only-users new file mode 100644 index 00000000000..f93a76ef622 --- /dev/null +++ b/changes/42882-42880-42884-allow-creation-of-api-only-users @@ -0,0 +1,3 @@ +- Added POST /users/api_only endpoint for creating API-only users. +- Added PATCH /users/api_only/{id} for updating existing API-only users. +- Updated `fleetctl user create --api-only` removing email/password field requirements. diff --git a/cmd/fleetctl/fleetctl/user.go b/cmd/fleetctl/fleetctl/user.go index 8227284e69d..e83c992cfc5 100644 --- a/cmd/fleetctl/fleetctl/user.go +++ b/cmd/fleetctl/fleetctl/user.go @@ -38,9 +38,8 @@ func createUserCommand() *cli.Command { If a password is required and not provided by flag, the command will prompt for password input through stdin.`, Flags: []cli.Flag{ &cli.StringFlag{ - Name: emailFlagName, - Usage: "Email for new user (required)", - Required: true, + Name: emailFlagName, + Usage: "Email for new user. This can be omitted if using --api-only (otherwise required)", }, &cli.StringFlag{ Name: nameFlagName, @@ -49,7 +48,7 @@ func createUserCommand() *cli.Command { }, &cli.StringFlag{ Name: passwordFlagName, - Usage: "Password for new user", + Usage: "Password for new user. This can be omitted if using --api-only (otherwise required)", }, &cli.BoolFlag{ Name: ssoFlagName, @@ -125,6 +124,31 @@ func createUserCommand() *cli.Command { } } + if apiOnly { + sessionKey, err := client.CreateAPIOnlyUser(name, globalRole, teams) + if err != nil { + return fmt.Errorf("Failed to create user: %w", err) + } + + fmt.Fprintln(c.App.Writer, "Successfully created new user!") + if appCfg, cfgErr := client.GetAppConfig(); cfgErr == nil && + appCfg.License != nil && appCfg.License.IsPremium() { + fmt.Fprintln(c.App.Writer, "To further customize endpoints this API-only user has access to, head to the Fleet UI.") + } + + if sessionKey != nil && *sessionKey != "" { + // Prevents blocking if we are executing a test + if terminal.IsTerminal(int(os.Stdin.Fd())) { //nolint:gosec // ignore G115 + fmt.Fprint(c.App.Writer, "\nWhen you're ready to view the API token, press any key (will not be shown again): ") + if _, err := os.Stdin.Read(make([]byte, 1)); err != nil { + return fmt.Errorf("failed to read input: %w", err) + } + } + fmt.Fprintf(c.App.Writer, "The API token for your new user is: %s\n", *sessionKey) + } + return nil + } + if sso && len(password) > 0 { return errors.New("Password may not be provided for SSO users.") } diff --git a/cmd/fleetctl/fleetctl/users_test.go b/cmd/fleetctl/fleetctl/users_test.go index 21bedb74acb..7da5e99a60b 100644 --- a/cmd/fleetctl/fleetctl/users_test.go +++ b/cmd/fleetctl/fleetctl/users_test.go @@ -58,15 +58,11 @@ func TestUserCreateForcePasswordReset(t *testing.T) { ds.InviteByEmailFunc = func(ctx context.Context, email string) (*fleet.Invite, error) { return nil, ¬FoundError{} } + // createdUsers tracks users created during tests so Login can find them by email. + createdUsers := map[string]*fleet.User{} ds.UserByEmailFunc = func(ctx context.Context, email string) (*fleet.User, error) { - if email == "bar@example.com" { - apiOnlyUser := &fleet.User{ - ID: 1, - Email: email, - } - err := apiOnlyUser.SetPassword(pwd, 24, 10) - require.NoError(t, err) - return apiOnlyUser, nil + if u, ok := createdUsers[email]; ok { + return u, nil } return nil, ¬FoundError{} } @@ -91,35 +87,39 @@ func TestUserCreateForcePasswordReset(t *testing.T) { args []string expectedAdminForcePasswordReset bool displaysToken bool + isAPIOnly bool }{ { name: "sso", args: []string{"--email", "foo@example.com", "--name", "foo", "--sso"}, expectedAdminForcePasswordReset: false, - displaysToken: false, }, { name: "api-only", - args: []string{"--email", "bar@example.com", "--password", pwd, "--name", "bar", "--api-only"}, + args: []string{"--name", "bar", "--api-only"}, expectedAdminForcePasswordReset: false, displaysToken: true, + isAPIOnly: true, }, { + // --sso is ignored by the api-only endpoint, so a password-based user + // is always created and a token is always returned. name: "api-only-sso", args: []string{"--email", "baz@example.com", "--name", "baz", "--api-only", "--sso"}, expectedAdminForcePasswordReset: false, - displaysToken: false, + displaysToken: true, + isAPIOnly: true, }, { name: "non-sso-non-api-only", args: []string{"--email", "zoo@example.com", "--password", pwd, "--name", "zoo"}, expectedAdminForcePasswordReset: true, - displaysToken: false, }, } { ds.NewUserFuncInvoked = false ds.NewUserFunc = func(ctx context.Context, user *fleet.User) (*fleet.User, error) { assert.Equal(t, tc.expectedAdminForcePasswordReset, user.AdminForcedPasswordReset) + createdUsers[user.Email] = user return user, nil } @@ -127,9 +127,12 @@ func TestUserCreateForcePasswordReset(t *testing.T) { []string{"user", "create"}, tc.args..., )) - if tc.displaysToken { - require.Equal(t, stdout, fmt.Sprintf("Success! The API token for your new user is: %s\n", apiOnlyUserSessionKey)) - } else { + switch { + case tc.displaysToken: + require.Equal(t, fmt.Sprintf("Successfully created new user!\nThe API token for your new user is: %s\n", apiOnlyUserSessionKey), stdout) + case tc.isAPIOnly: + require.Equal(t, "Successfully created new user!\n", stdout) + default: require.Empty(t, stdout) } require.True(t, ds.NewUserFuncInvoked) diff --git a/server/datastore/mysql/migrations/tables/20260411000000_DropAuthorIdFromUserApiEndpoints.go b/server/datastore/mysql/migrations/tables/20260411000000_DropAuthorIdFromUserApiEndpoints.go new file mode 100644 index 00000000000..6863598fc5f --- /dev/null +++ b/server/datastore/mysql/migrations/tables/20260411000000_DropAuthorIdFromUserApiEndpoints.go @@ -0,0 +1,41 @@ +package tables + +import ( + "database/sql" + "fmt" +) + +func init() { + MigrationClient.AddMigration(Up_20260411000000, Down_20260411000000) +} + +func Up_20260411000000(tx *sql.Tx) error { + // Find the foreign key constraint name for the author_id column specifically. + var constraintName string + err := tx.QueryRow(` + SELECT CONSTRAINT_NAME + FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE + WHERE TABLE_NAME = 'user_api_endpoints' + AND COLUMN_NAME = 'author_id' + AND CONSTRAINT_SCHEMA = DATABASE() + AND REFERENCED_TABLE_NAME = 'users' + `).Scan(&constraintName) + if err != nil && err != sql.ErrNoRows { + return fmt.Errorf("look up author_id foreign key: %w", err) + } + + if constraintName != "" { + if _, err := tx.Exec(fmt.Sprintf( + `ALTER TABLE user_api_endpoints DROP FOREIGN KEY %s`, constraintName, + )); err != nil { + return fmt.Errorf("drop author_id foreign key: %w", err) + } + } + + _, err = tx.Exec(`ALTER TABLE user_api_endpoints DROP COLUMN author_id`) + return err +} + +func Down_20260411000000(tx *sql.Tx) error { + return nil +} diff --git a/server/datastore/mysql/users.go b/server/datastore/mysql/users.go index db5dc39014d..a24f6572490 100644 --- a/server/datastore/mysql/users.go +++ b/server/datastore/mysql/users.go @@ -82,6 +82,13 @@ func (ds *Datastore) NewUser(ctx context.Context, user *fleet.User) (*fleet.User if err := saveTeamsForUserDB(ctx, tx, user); err != nil { return err } + + if user.APIEndpoints != nil { + if err := replaceUserAPIEndpoints(ctx, tx, user.ID, user.APIEndpoints); err != nil { + return err + } + } + return nil }) if err != nil { @@ -111,6 +118,10 @@ func (ds *Datastore) findUser(ctx context.Context, searchCol string, searchVal i return nil, ctxerr.Wrap(ctx, err, "load teams") } + if err := ds.loadAPIEndpointsForUsers(ctx, []*fleet.User{user}); err != nil { + return nil, ctxerr.Wrap(ctx, err, "load api endpoints") + } + // When SSO is enabled, we can ignore forced password resets // However, we want to leave the db untouched, to cover cases where SSO is toggled if user.SSOEnabled { @@ -174,6 +185,10 @@ func (ds *Datastore) ListUsers(ctx context.Context, opt fleet.UserListOptions) ( return nil, ctxerr.Wrap(ctx, err, "load teams") } + if err := ds.loadAPIEndpointsForUsers(ctx, users); err != nil { + return nil, ctxerr.Wrap(ctx, err, "load api endpoints") + } + return users, nil } @@ -238,8 +253,7 @@ func (ds *Datastore) SaveUser(ctx context.Context, user *fleet.User) error { func (ds *Datastore) SaveUsers(ctx context.Context, users []*fleet.User) error { return ds.withTx(ctx, func(tx sqlx.ExtContext) error { for _, user := range users { - err := saveUserDB(ctx, tx, user) - if err != nil { + if err := saveUserDB(ctx, tx, user); err != nil { return err } } @@ -301,6 +315,57 @@ func saveUserDB(ctx context.Context, tx sqlx.ExtContext, user *fleet.User) error return err } + if user.APIEndpoints != nil { + if err := replaceUserAPIEndpoints(ctx, tx, user.ID, user.APIEndpoints); err != nil { + return err + } + } + + return nil +} + +// loadAPIEndpointsForUsers loads api_endpoints for any API-only users in the slice. +// Non-API-only users are left untouched. +func (ds *Datastore) loadAPIEndpointsForUsers(ctx context.Context, users []*fleet.User) error { + var apiOnlyIDs []uint + for _, u := range users { + if u.APIOnly { + apiOnlyIDs = append(apiOnlyIDs, u.ID) + } + } + if len(apiOnlyIDs) == 0 { + return nil + } + + query, args, err := sqlx.In( + `SELECT user_id, method, path FROM user_api_endpoints WHERE user_id IN (?) ORDER BY method, path`, + apiOnlyIDs, + ) + if err != nil { + return ctxerr.Wrap(ctx, err, "build load api endpoints query") + } + + var rows []struct { + UserID uint `db:"user_id"` + Method string `db:"method"` + Path string `db:"path"` + } + if err := sqlx.SelectContext(ctx, ds.reader(ctx), &rows, query, args...); err != nil { + return ctxerr.Wrap(ctx, err, "load api endpoints for users") + } + + byUserID := make(map[uint][]fleet.APIEndpointRef, len(apiOnlyIDs)) + for _, row := range rows { + byUserID[row.UserID] = append(byUserID[row.UserID], fleet.APIEndpointRef{ + Method: row.Method, + Path: row.Path, + }) + } + for _, u := range users { + if u.APIOnly { + u.APIEndpoints = byUserID[u.ID] // nil when no endpoints assigned + } + } return nil } @@ -507,3 +572,24 @@ func (ds *Datastore) UserSettings(ctx context.Context, userID uint) (*fleet.User } return settings, nil } + +// replaceUserAPIEndpoints replaces all API endpoint permissions for the given user. +func replaceUserAPIEndpoints(ctx context.Context, tx sqlx.ExtContext, userID uint, endpoints []fleet.APIEndpointRef) error { + if _, err := tx.ExecContext(ctx, `DELETE FROM user_api_endpoints WHERE user_id = ?`, userID); err != nil { + return ctxerr.Wrap(ctx, err, "delete user api endpoints") + } + if len(endpoints) == 0 { + return nil + } + placeholders := strings.Repeat("(?, ?, ?),", len(endpoints)) + placeholders = placeholders[:len(placeholders)-1] // trim trailing comma + args := make([]any, 0, len(endpoints)*3) + for _, ep := range endpoints { + args = append(args, userID, ep.Path, ep.Method) + } + _, err := tx.ExecContext(ctx, + `INSERT INTO user_api_endpoints (user_id, path, method) VALUES `+placeholders, + args..., + ) + return ctxerr.Wrap(ctx, err, "insert user api endpoints") +} diff --git a/server/fleet/service.go b/server/fleet/service.go index c653489aff9..ff8e6e55a51 100644 --- a/server/fleet/service.go +++ b/server/fleet/service.go @@ -195,6 +195,10 @@ type Service interface { // ModifyUser updates a user's parameters given a UserPayload. ModifyUser(ctx context.Context, userID uint, p UserPayload) (user *User, err error) + // ModifyAPIOnlyUser patches the allowed properties of an existing API-only user. + // Only name, global_role, teams and api_endpoints may be changed. + ModifyAPIOnlyUser(ctx context.Context, userID uint, p UserPayload) (user *User, err error) + // DeleteUser permanently deletes the user identified by the provided ID. DeleteUser(ctx context.Context, id uint) (*User, error) diff --git a/server/fleet/users.go b/server/fleet/users.go index 6e738451eca..23f778e41a2 100644 --- a/server/fleet/users.go +++ b/server/fleet/users.go @@ -24,6 +24,12 @@ type UserSummary struct { APIOnly bool `db:"api_only"` } +// APIEndpointRef represents an endpoint an API-only user has access to. +type APIEndpointRef struct { + Method string `json:"method"` + Path string `json:"path"` +} + // User is the model struct that represents a Fleet user. type User struct { UpdateCreateTimestamps @@ -50,6 +56,10 @@ type User struct { Settings *UserSettings `json:"settings,omitempty"` Deleted bool `json:"-" db:"deleted"` + + // APIEndpoints if this user is an API-only user, this returns + // a list of all end-points the user has access to. + APIEndpoints []APIEndpointRef `json:"api_endpoints,omitempty"` } type UserSettings struct { @@ -200,6 +210,10 @@ type UserPayload struct { NewPassword *string `json:"new_password,omitempty"` Settings *UserSettings `json:"settings,omitempty"` InviteID *uint `json:"-"` + + // If this is an API-only user, then this can be used to specify which + // API endpoints the user has access to + APIEndpoints *[]APIEndpointRef `json:"api_endpoints,omitempty"` } func (p *UserPayload) VerifyInviteCreate() error { @@ -352,6 +366,9 @@ func (p UserPayload) User(keySize, cost int) (*User, error) { if p.InviteID != nil { user.InviteID = p.InviteID } + if p.APIEndpoints != nil { + user.APIEndpoints = *p.APIEndpoints + } return user, nil } diff --git a/server/mock/service/service_mock.go b/server/mock/service/service_mock.go index 47065893b45..3637b2c6bd2 100644 --- a/server/mock/service/service_mock.go +++ b/server/mock/service/service_mock.go @@ -88,6 +88,8 @@ type ResetPasswordFunc func(ctx context.Context, token string, password string) type ModifyUserFunc func(ctx context.Context, userID uint, p fleet.UserPayload) (user *fleet.User, err error) +type ModifyAPIOnlyUserFunc func(ctx context.Context, userID uint, p fleet.UserPayload) (user *fleet.User, err error) + type DeleteUserFunc func(ctx context.Context, id uint) (*fleet.User, error) type ChangeUserEmailFunc func(ctx context.Context, token string) (string, error) @@ -1013,6 +1015,9 @@ type Service struct { ModifyUserFunc ModifyUserFunc ModifyUserFuncInvoked bool + ModifyAPIOnlyUserFunc ModifyAPIOnlyUserFunc + ModifyAPIOnlyUserFuncInvoked bool + DeleteUserFunc DeleteUserFunc DeleteUserFuncInvoked bool @@ -2487,6 +2492,13 @@ func (s *Service) ModifyUser(ctx context.Context, userID uint, p fleet.UserPaylo return s.ModifyUserFunc(ctx, userID, p) } +func (s *Service) ModifyAPIOnlyUser(ctx context.Context, userID uint, p fleet.UserPayload) (user *fleet.User, err error) { + s.mu.Lock() + s.ModifyAPIOnlyUserFuncInvoked = true + s.mu.Unlock() + return s.ModifyAPIOnlyUserFunc(ctx, userID, p) +} + func (s *Service) DeleteUser(ctx context.Context, id uint) (*fleet.User, error) { s.mu.Lock() s.DeleteUserFuncInvoked = true diff --git a/server/service/client_users.go b/server/service/client_users.go index 6845997515c..0a7b116882b 100644 --- a/server/service/client_users.go +++ b/server/service/client_users.go @@ -7,6 +7,27 @@ import ( "github.com/fleetdm/fleet/v4/server/fleet" ) +// CreateAPIOnlyUser creates a new API-only user via the dedicated endpoint. +// The email and password are generated server-side. +// Returns the API token for the new user. +func (c *Client) CreateAPIOnlyUser(name string, globalRole *string, teams []fleet.UserTeam) (*string, error) { + verb, path := "POST", "/api/latest/fleet/users/api_only" + payload := fleet.UserPayload{ + Name: &name, + GlobalRole: globalRole, + Teams: &teams, + } + if appCfg, err := c.GetAppConfig(); err == nil && appCfg.License != nil && appCfg.License.IsPremium() { + allEndpoints := []fleet.APIEndpointRef{{Path: "*", Method: "*"}} + payload.APIEndpoints = &allEndpoints + } + var responseBody createUserResponse + if err := c.authenticatedRequest(payload, verb, path, &responseBody); err != nil { + return nil, err + } + return responseBody.Token, nil +} + // CreateUser creates a new user, skipping the invitation process. // // The session key (aka API token) is returned only when creating diff --git a/server/service/handler.go b/server/service/handler.go index 6fbe3246099..7ec1088d1f7 100644 --- a/server/service/handler.go +++ b/server/service/handler.go @@ -318,6 +318,8 @@ func attachFleetAPIRoutes(r *mux.Router, svc fleet.Service, config config.FleetC ue.GET("/api/_version_/fleet/users", listUsersEndpoint, listUsersRequest{}) ue.POST("/api/_version_/fleet/users/admin", createUserEndpoint, createUserRequest{}) + ue.POST("/api/_version_/fleet/users/api_only", createAPIOnlyUserEndpoint, createAPIOnlyUserRequest{}) + ue.PATCH("/api/_version_/fleet/users/api_only/{id:[0-9]+}", modifyAPIOnlyUserEndpoint, modifyAPIOnlyUserRequest{}) ue.GET("/api/_version_/fleet/users/{id:[0-9]+}", getUserEndpoint, getUserRequest{}) ue.PATCH("/api/_version_/fleet/users/{id:[0-9]+}", modifyUserEndpoint, modifyUserRequest{}) ue.DELETE("/api/_version_/fleet/users/{id:[0-9]+}", deleteUserEndpoint, deleteUserRequest{}) diff --git a/server/service/integration_core_test.go b/server/service/integration_core_test.go index aa0be49cc4c..dd4586b2bef 100644 --- a/server/service/integration_core_test.go +++ b/server/service/integration_core_test.go @@ -333,6 +333,108 @@ func (s *integrationTestSuite) TestUserCreationWrongTeamErrors() { assertBodyContains(t, resp, `fleet with id 9999 does not exist`) } +func (s *integrationTestSuite) TestCreateAPIOnlyUser() { + t := s.T() + + // missing name → 422 + s.Do("POST", "/api/latest/fleet/users/api_only", map[string]any{ + "global_role": "observer", + }, http.StatusUnprocessableEntity) + + // neither global_role nor fleets → 422 + s.Do("POST", "/api/latest/fleet/users/api_only", map[string]any{ + "name": "Jane Doe", + }, http.StatusUnprocessableEntity) + + // fleets without premium → 402 + s.Do("POST", "/api/latest/fleet/users/api_only", map[string]any{ + "name": "Jane Doe", + "fleets": []map[string]any{{"id": 9999, "role": "observer"}}, + }, http.StatusPaymentRequired) + + // api_endpoints without premium → 402 + s.Do("POST", "/api/latest/fleet/users/api_only", map[string]any{ + "name": "Jane Doe", + "global_role": "observer", + "api_endpoints": []map[string]any{ + {"method": "GET", "path": "/api/v1/fleet/hosts/:id"}, + }, + }, http.StatusPaymentRequired) + + // both global_role and fleets without premium → 402 (premium check fires first) + s.Do("POST", "/api/latest/fleet/users/api_only", map[string]any{ + "name": "Jane Doe", + "global_role": "observer", + "fleets": []map[string]any{{"id": 9999, "role": "observer"}}, + }, http.StatusPaymentRequired) + + // successful creation with global_role only (no premium features) → 200 + var createResp struct { + User struct { + ID uint `json:"id"` + Name string `json:"name"` + Email string `json:"email"` + APIOnly bool `json:"api_only"` + GlobalRole *string `json:"global_role"` + } `json:"user"` + Token string `json:"token"` + Err string `json:"error,omitempty"` + } + + s.DoJSON("POST", "/api/latest/fleet/users/api_only", map[string]any{ + "name": "Jane Doe", + "global_role": "observer", + }, http.StatusOK, &createResp) + + require.NotEmpty(t, createResp.Token, "token must be set") + require.NotZero(t, createResp.User.ID, "user ID must be set") + require.Equal(t, "Jane Doe", createResp.User.Name) + require.NotEmpty(t, createResp.User.Email) + require.True(t, createResp.User.APIOnly, "user must be api_only") + require.NotNil(t, createResp.User.GlobalRole) + require.Equal(t, "observer", *createResp.User.GlobalRole) +} + +func (s *integrationTestSuite) TestPatchAPIOnlyUser() { + t := s.T() + + // Create an API-only user on the free tier (global_role only, no premium features). + var createResp struct { + User struct { + ID uint `json:"id"` + } `json:"user"` + Err string `json:"error,omitempty"` + } + s.DoJSON("POST", "/api/latest/fleet/users/api_only", map[string]any{ + "name": "API User", + "global_role": "observer", + }, http.StatusOK, &createResp) + require.NotZero(t, createResp.User.ID) + apiUserID := createResp.User.ID + + // Unauthenticated PATCH → 401. + // Must send a valid JSON body ({}) to avoid pre-auth decode error (400 vs 401). + s.DoRawNoAuth("PATCH", fmt.Sprintf("/api/latest/fleet/users/api_only/%d", apiUserID), []byte(`{}`), http.StatusUnauthorized) + + // PATCH non-existent user → 404. + s.Do("PATCH", "/api/latest/fleet/users/api_only/999999", map[string]any{ + "name": "New Name", + }, http.StatusNotFound) + + // PATCH a non-API-only user (admin1) → 422. + admin := s.users["admin1@example.com"] + s.Do("PATCH", fmt.Sprintf("/api/latest/fleet/users/api_only/%d", admin.ID), map[string]any{ + "name": "New Name", + }, http.StatusUnprocessableEntity) + + // PATCH api_endpoints without premium → 402. + s.Do("PATCH", fmt.Sprintf("/api/latest/fleet/users/api_only/%d", apiUserID), map[string]any{ + "api_endpoints": []map[string]any{ + {"method": "GET", "path": "/api/v1/fleet/config"}, + }, + }, http.StatusPaymentRequired) +} + func (s *integrationTestSuite) TestQueryCreationLogsActivity() { t := s.T() diff --git a/server/service/integration_enterprise_test.go b/server/service/integration_enterprise_test.go index 603ac5ca9da..3fb4d8f43a1 100644 --- a/server/service/integration_enterprise_test.go +++ b/server/service/integration_enterprise_test.go @@ -28331,3 +28331,361 @@ func (s *integrationEnterpriseTestSuite) TestListAPIEndpoints() { require.NotEmpty(t, resp.APIEndpoints[0].Path) require.NotEmpty(t, resp.APIEndpoints[0].DisplayName) } + +func (s *integrationEnterpriseTestSuite) TestCreateAPIOnlyUserPremium() { + t := s.T() + + // Create a team to use for fleet-scoped assignments. + team, err := s.ds.NewTeam(context.Background(), &fleet.Team{Name: t.Name() + "_team"}) + require.NoError(t, err) + + // --- Validation still enforced under premium --- + + // missing name → 422 (same as free tier) + s.Do("POST", "/api/latest/fleet/users/api_only", map[string]any{ + "fleets": []map[string]any{{"id": team.ID, "role": "observer"}}, + }, http.StatusUnprocessableEntity) + + // neither global_role nor fleets → 422 + s.Do("POST", "/api/latest/fleet/users/api_only", map[string]any{ + "name": "Premium User", + }, http.StatusUnprocessableEntity) + + // global_role AND fleets together → 422 (mutual exclusivity) + s.Do("POST", "/api/latest/fleet/users/api_only", map[string]any{ + "name": "Premium User", + "global_role": "observer", + "fleets": []map[string]any{{"id": team.ID, "role": "observer"}}, + }, http.StatusUnprocessableEntity) + + // invalid api_endpoint (not in catalog) → 422 + s.Do("POST", "/api/latest/fleet/users/api_only", map[string]any{ + "name": "Premium User", + "global_role": "observer", + "api_endpoints": []map[string]any{ + {"method": "GET", "path": "/api/v1/fleet/nonexistent/endpoint"}, + }, + }, http.StatusUnprocessableEntity) + + // wildcard mixed with other entries → 422 + s.Do("POST", "/api/latest/fleet/users/api_only", map[string]any{ + "name": "Premium User", + "global_role": "observer", + "api_endpoints": []map[string]any{ + {"method": "*", "path": "*"}, + {"method": "GET", "path": "/api/v1/fleet/config"}, + }, + }, http.StatusUnprocessableEntity) + + // --- Premium features work under premium --- + + // Create with wildcard api_endpoint → 200 + var createRespWildcard struct { + User struct { + ID uint `json:"id"` + Name string `json:"name"` + Email string `json:"email"` + APIOnly bool `json:"api_only"` + GlobalRole *string `json:"global_role"` + APIEndpoints []struct { + Method string `json:"method"` + Path string `json:"path"` + } `json:"api_endpoints"` + } `json:"user"` + Token string `json:"token"` + Err string `json:"error,omitempty"` + } + s.DoJSON("POST", "/api/latest/fleet/users/api_only", map[string]any{ + "name": "Wildcard API User", + "global_role": "observer", + "api_endpoints": []map[string]any{ + {"method": "*", "path": "*"}, + }, + }, http.StatusOK, &createRespWildcard) + + require.NotEmpty(t, createRespWildcard.Token) + require.NotZero(t, createRespWildcard.User.ID) + require.Equal(t, "Wildcard API User", createRespWildcard.User.Name) + require.NotEmpty(t, createRespWildcard.User.Email) + require.True(t, createRespWildcard.User.APIOnly) + require.NotNil(t, createRespWildcard.User.GlobalRole) + require.Equal(t, "observer", *createRespWildcard.User.GlobalRole) + require.Len(t, createRespWildcard.User.APIEndpoints, 1) + require.Equal(t, "*", createRespWildcard.User.APIEndpoints[0].Method) + require.Equal(t, "*", createRespWildcard.User.APIEndpoints[0].Path) + + // Create with global_role and api_endpoints → 200 + var createRespGlobal struct { + User struct { + ID uint `json:"id"` + Name string `json:"name"` + Email string `json:"email"` + APIOnly bool `json:"api_only"` + GlobalRole *string `json:"global_role"` + APIEndpoints []struct { + Method string `json:"method"` + Path string `json:"path"` + } `json:"api_endpoints"` + } `json:"user"` + Token string `json:"token"` + Err string `json:"error,omitempty"` + } + s.DoJSON("POST", "/api/latest/fleet/users/api_only", map[string]any{ + "name": "Global API User", + "global_role": "observer", + "api_endpoints": []map[string]any{ + {"method": "GET", "path": "/api/v1/fleet/config"}, + {"method": "GET", "path": "/api/v1/fleet/version"}, + }, + }, http.StatusOK, &createRespGlobal) + + require.NotEmpty(t, createRespGlobal.Token) + require.NotZero(t, createRespGlobal.User.ID) + require.Equal(t, "Global API User", createRespGlobal.User.Name) + require.NotEmpty(t, createRespGlobal.User.Email) + require.True(t, createRespGlobal.User.APIOnly) + require.NotNil(t, createRespGlobal.User.GlobalRole) + require.Equal(t, "observer", *createRespGlobal.User.GlobalRole) + require.Len(t, createRespGlobal.User.APIEndpoints, 2) + + // Create with fleets (team assignment) → 200 + var createRespTeam struct { + User struct { + ID uint `json:"id"` + Name string `json:"name"` + Email string `json:"email"` + APIOnly bool `json:"api_only"` + Teams []struct { + ID uint `json:"id"` + Role string `json:"role"` + } `json:"teams"` + } `json:"user"` + Token string `json:"token"` + Err string `json:"error,omitempty"` + } + s.DoJSON("POST", "/api/latest/fleet/users/api_only", map[string]any{ + "name": "Team API User", + "fleets": []map[string]any{{"id": team.ID, "role": "observer"}}, + }, http.StatusOK, &createRespTeam) + + require.NotEmpty(t, createRespTeam.Token) + require.NotZero(t, createRespTeam.User.ID) + require.Equal(t, "Team API User", createRespTeam.User.Name) + require.NotEmpty(t, createRespTeam.User.Email) + require.True(t, createRespTeam.User.APIOnly) + require.Len(t, createRespTeam.User.Teams, 1) + require.Equal(t, team.ID, createRespTeam.User.Teams[0].ID) + require.Equal(t, "observer", createRespTeam.User.Teams[0].Role) +} + +func (s *integrationEnterpriseTestSuite) TestPatchAPIOnlyUserPremium() { + t := s.T() + + // Create a team for fleet-scoped assignments. + team, err := s.ds.NewTeam(context.Background(), &fleet.Team{Name: t.Name() + "_team"}) + require.NoError(t, err) + + // Create a non-API-only user to verify the endpoint rejects it. + nonAPIUser := &fleet.User{ + Name: "Non API User", + Email: "non-api-patch@example.com", + GlobalRole: ptr.String(fleet.RoleObserver), + } + require.NoError(t, nonAPIUser.SetPassword(test.GoodPassword, 10, 10)) + nonAPIUser, err = s.ds.NewUser(context.Background(), nonAPIUser) + require.NoError(t, err) + + // Helper struct for parsing PATCH responses. + type patchResp struct { + User struct { + ID uint `json:"id"` + Name string `json:"name"` + Email string `json:"email"` + APIOnly bool `json:"api_only"` + GlobalRole *string `json:"global_role"` + APIEndpoints []struct { + Method string `json:"method"` + Path string `json:"path"` + } `json:"api_endpoints"` + Teams []struct { + ID uint `json:"id"` + Role string `json:"role"` + } `json:"teams"` + } `json:"user"` + Err string `json:"error,omitempty"` + } + + // Create the API-only user we'll be patching throughout the test. + var createResp struct { + User struct { + ID uint `json:"id"` + } `json:"user"` + Token string `json:"token"` + } + s.DoJSON("POST", "/api/latest/fleet/users/api_only", map[string]any{ + "name": "Patch Target", + "global_role": "observer", + "api_endpoints": []map[string]any{ + {"method": "GET", "path": "/api/v1/fleet/version"}, + }, + }, http.StatusOK, &createResp) + require.NotZero(t, createResp.User.ID) + uid := createResp.User.ID + patchURL := fmt.Sprintf("/api/latest/fleet/users/api_only/%d", uid) + + // PATCH non-existent user → 404. + s.Do("PATCH", "/api/latest/fleet/users/api_only/999999", map[string]any{ + "name": "Ghost", + }, http.StatusNotFound) + + // PATCH a non-API-only user → 422. + s.Do("PATCH", fmt.Sprintf("/api/latest/fleet/users/api_only/%d", nonAPIUser.ID), map[string]any{ + "name": "New Name", + }, http.StatusUnprocessableEntity) + + // PATCH with invalid api_endpoint (not in catalog) → 422. + s.Do("PATCH", patchURL, map[string]any{ + "api_endpoints": []map[string]any{ + {"method": "GET", "path": "/api/v1/fleet/nonexistent/endpoint"}, + }, + }, http.StatusUnprocessableEntity) + + // PATCH with wildcard mixed with other entries → 422. + s.Do("PATCH", patchURL, map[string]any{ + "api_endpoints": []map[string]any{ + {"method": "*", "path": "*"}, + {"method": "GET", "path": "/api/v1/fleet/version"}, + }, + }, http.StatusUnprocessableEntity) + + // PATCH with empty api_endpoints slice → 422. + s.Do("PATCH", patchURL, map[string]any{ + "api_endpoints": []map[string]any{}, + }, http.StatusUnprocessableEntity) + + // PATCH updating name only → 200. + var respName patchResp + s.DoJSON("PATCH", patchURL, map[string]any{ + "name": "Patched Name", + }, http.StatusOK, &respName) + require.Equal(t, "Patched Name", respName.User.Name) + require.True(t, respName.User.APIOnly) + + // PATCH updating global_role → 200. + var respRole patchResp + s.DoJSON("PATCH", patchURL, map[string]any{ + "global_role": "admin", + }, http.StatusOK, &respRole) + require.NotNil(t, respRole.User.GlobalRole) + require.Equal(t, "admin", *respRole.User.GlobalRole) + + // PATCH global_role AND fleets together → 422 (mutual exclusivity). + s.Do("PATCH", patchURL, map[string]any{ + "global_role": "observer", + "fleets": []map[string]any{{"id": team.ID, "role": "observer"}}, + }, http.StatusUnprocessableEntity) + + // PATCH updating fleets (team role) → 200. + var respTeam patchResp + s.DoJSON("PATCH", patchURL, map[string]any{ + "fleets": []map[string]any{{"id": team.ID, "role": "observer"}}, + }, http.StatusOK, &respTeam) + require.Len(t, respTeam.User.Teams, 1) + require.Equal(t, team.ID, respTeam.User.Teams[0].ID) + require.Equal(t, "observer", respTeam.User.Teams[0].Role) + + // PATCH updating api_endpoints with catalog entries → 200. + var respEndpoints patchResp + s.DoJSON("PATCH", patchURL, map[string]any{ + "api_endpoints": []map[string]any{ + {"method": "GET", "path": "/api/v1/fleet/config"}, + {"method": "GET", "path": "/api/v1/fleet/version"}, + }, + }, http.StatusOK, &respEndpoints) + require.Len(t, respEndpoints.User.APIEndpoints, 2) + + // PATCH updating api_endpoints to single wildcard → 200. + var respWildcard patchResp + s.DoJSON("PATCH", patchURL, map[string]any{ + "api_endpoints": []map[string]any{ + {"method": "*", "path": "*"}, + }, + }, http.StatusOK, &respWildcard) + require.Len(t, respWildcard.User.APIEndpoints, 1) + require.Equal(t, "*", respWildcard.User.APIEndpoints[0].Method) + require.Equal(t, "*", respWildcard.User.APIEndpoints[0].Path) +} + +func (s *integrationEnterpriseTestSuite) TestGetUserReturnsAPIEndpoints() { + t := s.T() + + // Shared response type for GET /users/:id and items in GET /users. + type userJSON struct { + ID uint `json:"id"` + APIOnly bool `json:"api_only"` + APIEndpoints []struct { + Method string `json:"method"` + Path string `json:"path"` + } `json:"api_endpoints"` + } + type getUserResp struct { + User userJSON `json:"user"` + Err string `json:"error,omitempty"` + } + type listUsersResp struct { + Users []userJSON `json:"users"` + Err string `json:"error,omitempty"` + } + + // Create an API-only user with specific catalog endpoints. + var createResp getUserResp + s.DoJSON("POST", "/api/latest/fleet/users/api_only", map[string]any{ + "name": "GET Endpoint User", + "global_role": "observer", + "api_endpoints": []map[string]any{ + {"method": "GET", "path": "/api/v1/fleet/config"}, + {"method": "GET", "path": "/api/v1/fleet/version"}, + }, + }, http.StatusOK, &createResp) + require.NotZero(t, createResp.User.ID) + apiUserID := createResp.User.ID + + // GET /users/:id should include api_endpoints. + var getResp getUserResp + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/users/%d", apiUserID), nil, http.StatusOK, &getResp) + require.True(t, getResp.User.APIOnly) + require.Len(t, getResp.User.APIEndpoints, 2) + + // GET /users should include api_endpoints for the API-only user. + var listResp listUsersResp + s.DoJSON("GET", "/api/latest/fleet/users", nil, http.StatusOK, &listResp) + var found *userJSON + for i := range listResp.Users { + if listResp.Users[i].ID == apiUserID { + found = &listResp.Users[i] + break + } + } + require.NotNil(t, found, "API-only user should appear in list") + require.Len(t, found.APIEndpoints, 2) + + // A regular (non-API-only) user should have no api_endpoints in either response. + adminID := uint(1) // admin1@example.com created in suite setup + var getAdminResp getUserResp + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/users/%d", adminID), nil, http.StatusOK, &getAdminResp) + require.False(t, getAdminResp.User.APIOnly) + require.Empty(t, getAdminResp.User.APIEndpoints) + + // Patch the API-only user to use a wildcard; GET should reflect the change. + s.Do("PATCH", fmt.Sprintf("/api/latest/fleet/users/api_only/%d", apiUserID), map[string]any{ + "api_endpoints": []map[string]any{ + {"method": "*", "path": "*"}, + }, + }, http.StatusOK) + + var getAfterPatch getUserResp + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/users/%d", apiUserID), nil, http.StatusOK, &getAfterPatch) + require.Len(t, getAfterPatch.User.APIEndpoints, 1) + require.Equal(t, "*", getAfterPatch.User.APIEndpoints[0].Method) + require.Equal(t, "*", getAfterPatch.User.APIEndpoints[0].Path) +} diff --git a/server/service/users.go b/server/service/users.go index 23226f4fe1d..56ef82bf39f 100644 --- a/server/service/users.go +++ b/server/service/users.go @@ -8,9 +8,11 @@ import ( "fmt" "html/template" "net/http" + "slices" "time" "github.com/fleetdm/fleet/v4/server" + apiendpoints "github.com/fleetdm/fleet/v4/server/api_endpoints" "github.com/fleetdm/fleet/v4/server/authz" authz_ctx "github.com/fleetdm/fleet/v4/server/contexts/authz" "github.com/fleetdm/fleet/v4/server/contexts/ctxdb" @@ -41,6 +43,16 @@ func (r createUserResponse) Error() error { return r.Err } func createUserEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) { req := request.(*createUserRequest) + + if req.APIEndpoints != nil { + return createUserResponse{ + Err: fleet.NewInvalidArgumentError( + "api_endpoints", + "API endpoints might only be specified when creating an API only user via the UI", + ), + }, nil + } + user, sessionKey, err := svc.CreateUser(ctx, req.UserPayload) if err != nil { return createUserResponse{Err: err}, nil @@ -53,6 +65,63 @@ func createUserEndpoint(ctx context.Context, request interface{}, svc fleet.Serv var errMailerRequiredForMFA = badRequest("Email must be set up to enable Fleet MFA") +func validateAPIEndpointRefs(ctx context.Context, refs *[]fleet.APIEndpointRef, requireNonEmpty bool) error { + if refs == nil || len(*refs) == 0 { + if requireNonEmpty { + return ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("api_endpoints", "At least one API endpoint must be specified for API only users")) + } + if refs != nil { + // Explicitly provided empty slice is never valid. + return ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("api_endpoints", "At least one API endpoint must be specified for API only users")) + } + return nil + } + + entries := *refs + + // A single wildcard entry grants access to all endpoints — no catalog check needed. + wildcardIdx := slices.IndexFunc(entries, func(e fleet.APIEndpointRef) bool { + return e.Method == "*" && e.Path == "*" + }) + if wildcardIdx >= 0 { + if len(entries) > 1 { + return ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("api_endpoints", "wildcard endpoint (method: *, path: *) must be the only entry")) + } + return nil + } + + allEndpoints := apiendpoints.GetAPIEndpoints() + fpMap := make(map[string]fleet.APIEndpoint, len(allEndpoints)) + for _, ep := range allEndpoints { + fpMap[ep.Fingerprint()] = ep + } + seen := make(map[string]struct{}, len(entries)) + hasDuplicates := false + hasUnknown := false + for _, ref := range entries { + fp := fleet.NewAPIEndpointFromTpl(ref.Method, ref.Path).Fingerprint() + if _, dup := seen[fp]; dup { + hasDuplicates = true + continue + } + seen[fp] = struct{}{} + if _, ok := fpMap[fp]; !ok { + hasUnknown = true + } + } + invalid := &fleet.InvalidArgumentError{} + if hasDuplicates { + invalid.Append("api_endpoints", "one or more api_endpoints entries are duplicated") + } + if hasUnknown { + invalid.Append("api_endpoints", "one or more api_endpoints entries are invalid") + } + if invalid.HasErrors() { + return ctxerr.Wrap(ctx, invalid, "validate api_endpoints") + } + return nil +} + func (svc *Service) CreateUser(ctx context.Context, p fleet.UserPayload) (*fleet.User, *string, error) { var teams []fleet.UserTeam if p.Teams != nil { @@ -66,6 +135,23 @@ func (svc *Service) CreateUser(ctx context.Context, p fleet.UserPayload) (*fleet return nil, nil, ctxerr.Wrap(ctx, err, "verify user payload") } + // Do not allow creating a user with any Premium-only features on Fleet Free. + if !license.IsPremium(ctx) { + var teamRoles []fleet.UserTeam + if p.Teams != nil { + teamRoles = *p.Teams + } + if fleet.PremiumRolesPresent(p.GlobalRole, teamRoles) { + return nil, nil, fleet.ErrMissingLicense + } + if p.APIEndpoints != nil { + return nil, nil, fleet.ErrMissingLicense + } + if p.APIOnly != nil && *p.APIOnly && len(teamRoles) > 0 { + return nil, nil, fleet.ErrMissingLicense + } + } + if teams != nil { // Validate that the teams exist teamsSummary, err := svc.ds.TeamsSummary(ctx) @@ -112,14 +198,13 @@ func (svc *Service) CreateUser(ctx context.Context, p fleet.UserPayload) (*fleet } } - // Do not allow creating a user with a Premium-only role on Fleet Free. - if !license.IsPremium(ctx) { - var teamRoles []fleet.UserTeam - if p.Teams != nil { - teamRoles = *p.Teams - } - if fleet.PremiumRolesPresent(p.GlobalRole, teamRoles) { - return nil, nil, fleet.ErrMissingLicense + if p.APIOnly != nil && *p.APIOnly { + // API-Endpoints is a premium only feature, + // so we only require it if creating an API only + // user under premium + requireNonEmpty := license.IsPremium(ctx) + if err := validateAPIEndpointRefs(ctx, p.APIEndpoints, requireNonEmpty); err != nil { + return nil, nil, err } } @@ -133,7 +218,7 @@ func (svc *Service) CreateUser(ctx context.Context, p fleet.UserPayload) (*fleet if user.APIOnly && !user.SSOEnabled { if p.Password == nil { // Should not happen but let's log just in case. - svc.logger.ErrorContext(ctx, "password not set during admin user creation", "err", err) + svc.logger.ErrorContext(ctx, "password not set during admin user creation") } else { // Create a session for the API-only user by logging in. _, session, err := svc.Login(ctx, user.Email, *p.Password, false) @@ -147,12 +232,136 @@ func (svc *Service) CreateUser(ctx context.Context, p fleet.UserPayload) (*fleet return user, sessionKey, nil } +//////////////////////////////////////////////////////////////////////////////// +// Create API-Only user +//////////////////////////////////////////////////////////////////////////////// + +type createAPIOnlyUserRequest struct { + Name *string `json:"name,omitempty"` + GlobalRole *string `json:"global_role,omitempty"` + Teams *[]fleet.UserTeam `json:"teams,omitempty" renameto:"fleets"` + APIEndpoints *[]fleet.APIEndpointRef `json:"api_endpoints,omitempty"` +} + +func createAPIOnlyUserEndpoint(ctx context.Context, request any, svc fleet.Service) (fleet.Errorer, error) { + req := request.(*createAPIOnlyUserRequest) + + pwd, err := server.GenerateRandomPwd() + if err != nil { + return createUserResponse{ + Err: ctxerr.Wrap(ctx, err, "generate user password"), + }, nil + } + + // This end-point is called only from the UI + // so we should have a logged-in user at this point. + vc, ok := viewer.FromContext(ctx) + if !ok { + return createUserResponse{ + Err: ctxerr.New(ctx, "failed to get logged user"), + }, nil + } + email, err := server.GenerateRandomEmail(vc.Email()) + if err != nil { + return createUserResponse{ + Err: ctxerr.Wrap(ctx, err, "generate user email"), + }, nil + } + + user, token, err := svc.CreateUser(ctx, fleet.UserPayload{ + Name: req.Name, + Email: &email, + Password: &pwd, + APIOnly: new(true), + AdminForcedPasswordReset: new(false), + GlobalRole: req.GlobalRole, + Teams: req.Teams, + APIEndpoints: req.APIEndpoints, + }) + if err != nil { + return createUserResponse{Err: err}, nil + } + + return createUserResponse{ + User: user, + Token: token, + }, nil +} + +//////////////////////////////////////////////////////////////////////////////// +// Patch API-Only User +//////////////////////////////////////////////////////////////////////////////// + +type modifyAPIOnlyUserRequest struct { + ID uint `json:"-" url:"id"` + Name *string `json:"name,omitempty"` + GlobalRole *string `json:"global_role,omitempty"` + Teams *[]fleet.UserTeam `json:"teams,omitempty" renameto:"fleets"` + APIEndpoints *[]fleet.APIEndpointRef `json:"api_endpoints,omitempty"` +} + +type modifyAPIOnlyUserResponse struct { + User *fleet.User `json:"user,omitempty"` + Err error `json:"error,omitempty"` +} + +func (r modifyAPIOnlyUserResponse) Error() error { return r.Err } + +func modifyAPIOnlyUserEndpoint(ctx context.Context, request any, svc fleet.Service) (fleet.Errorer, error) { + req := request.(*modifyAPIOnlyUserRequest) + + user, err := svc.ModifyAPIOnlyUser(ctx, req.ID, fleet.UserPayload{ + Name: req.Name, + GlobalRole: req.GlobalRole, + Teams: req.Teams, + APIEndpoints: req.APIEndpoints, + }) + if err != nil { + return modifyAPIOnlyUserResponse{Err: err}, nil + } + return modifyAPIOnlyUserResponse{User: user}, nil +} + +func (svc *Service) ModifyAPIOnlyUser(ctx context.Context, userID uint, p fleet.UserPayload) (*fleet.User, error) { + // Authorize before hitting the DB so that callers without write access cannot + // enumerate user IDs by observing 404 vs non-404 responses. + if err := svc.authz.Authorize(ctx, &fleet.User{}, fleet.ActionWrite); err != nil { + return nil, err + } + + vc, ok := viewer.FromContext(ctx) + if !ok { + return nil, ctxerr.New(ctx, "viewer not present") + } + if vc.UserID() == userID { + return nil, ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("id", "cannot modify your own API-only user")) + } + + return svc.ModifyUser(ctx, userID, fleet.UserPayload{ + Name: p.Name, + GlobalRole: p.GlobalRole, + Teams: p.Teams, + APIOnly: new(true), + APIEndpoints: p.APIEndpoints, + }) +} + //////////////////////////////////////////////////////////////////////////////// // Create User From Invite //////////////////////////////////////////////////////////////////////////////// func createUserFromInviteEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) { req := request.(*createUserRequest) + + if req.APIEndpoints != nil { + return createUserResponse{ + Err: fleet.NewInvalidArgumentError( + "api_endpoints", + "API endpoints might only be specified when creating an API only user", + ), + }, nil + } + user, err := svc.CreateUserFromInvite(ctx, req.UserPayload) if err != nil { return createUserResponse{Err: err}, nil @@ -411,7 +620,7 @@ func (svc *Service) ModifyUser(ctx context.Context, userID uint, p fleet.UserPay return nil, err } - // Do not allow setting a Premium-only role on Fleet Free. + // Do not allow setting any Premium-only features on Fleet Free. if !license.IsPremium(ctx) { var teamRoles []fleet.UserTeam if p.Teams != nil { @@ -420,6 +629,13 @@ func (svc *Service) ModifyUser(ctx context.Context, userID uint, p fleet.UserPay if fleet.PremiumRolesPresent(p.GlobalRole, teamRoles) { return nil, fleet.ErrMissingLicense } + if p.APIEndpoints != nil { + return nil, fleet.ErrMissingLicense + } + // Team-based API-only users are a premium feature. + if p.APIOnly != nil && *p.APIOnly && len(teamRoles) > 0 { + return nil, fleet.ErrMissingLicense + } } vc, ok := viewer.FromContext(ctx) @@ -431,6 +647,23 @@ func (svc *Service) ModifyUser(ctx context.Context, userID uint, p fleet.UserPay return nil, ctxerr.Wrap(ctx, err, "verify user payload") } + if p.APIOnly != nil && *p.APIOnly && !user.APIOnly { + return nil, ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("id", "user is not an API-only user")) + } + if p.APIEndpoints != nil && !user.APIOnly { + return nil, ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("api_endpoints", "API endpoints can only be specified for API only users")) + } + if p.APIEndpoints != nil { + // Changing endpoint permissions is a privileged operation — same level as + // changing roles. This prevents an API-only user from expanding their own access. + if err := svc.authz.Authorize(ctx, user, fleet.ActionWriteRole); err != nil { + return nil, err + } + } + if err := validateAPIEndpointRefs(ctx, p.APIEndpoints, false); err != nil { + return nil, err + } + if p.MFAEnabled != nil { if *p.MFAEnabled && !user.MFAEnabled { lic, _ := license.FromContext(ctx) @@ -530,6 +763,10 @@ func (svc *Service) ModifyUser(ctx context.Context, userID uint, p fleet.UserPay user.Settings = p.Settings } + if p.APIEndpoints != nil { + user.APIEndpoints = *p.APIEndpoints + } + currentUser := authz.UserFromContext(ctx) var isGlobalAdminDemotion bool diff --git a/server/utils.go b/server/utils.go index 3137145cf23..c566ad17a7e 100644 --- a/server/utils.go +++ b/server/utils.go @@ -9,12 +9,47 @@ import ( "errors" "fmt" "html/template" + "math/big" "strings" "github.com/fleetdm/fleet/v4/server/bindata" platformhttp "github.com/fleetdm/fleet/v4/server/platform/http" ) +// GenerateRandomEmail generates a random email using baseEmail as the base. +// For example: GenerateRandomEmail('juan@fleetdm.com') -> 'juan+somerandomtext@fleetdm.com' +func GenerateRandomEmail(baseEmail string) (string, error) { + emailSuffix, err := GenerateRandomURLSafeText(10) + if err != nil { + return "", err + } + + atIdx := strings.Index(baseEmail, "@") + var email string + if atIdx < 0 { + email = fmt.Sprintf("%s+%s", baseEmail, emailSuffix) + } else { + email = fmt.Sprintf("%s+%s%s", baseEmail[:atIdx], emailSuffix, baseEmail[atIdx:]) + } + return email, nil +} + +// GenerateRandomPwd generates a random text that +// complies with Fleet's password requirements. +func GenerateRandomPwd() (string, error) { + pwd, err := GenerateRandomText(14) + if err != nil { + return "", err + } + + n, err := rand.Int(rand.Reader, big.NewInt(int64(100))) + if err != nil { + return "", err + } + + return fmt.Sprintf("%s%d!", pwd, n.Int64()), nil +} + // GenerateRandomText return a string generated by filling in keySize bytes with // random data and then base64 encoding those bytes func GenerateRandomText(keySize int) (string, error) { diff --git a/server/utils_test.go b/server/utils_test.go index ed186a75566..d6de4a140cf 100644 --- a/server/utils_test.go +++ b/server/utils_test.go @@ -4,6 +4,7 @@ import ( "encoding/base64" "errors" "net/url" + "strings" "testing" "github.com/stretchr/testify/require" @@ -150,3 +151,40 @@ func TestRemoveDuplicatesFromSlice(t *testing.T) { ) } } + +func TestGenerateRandomEmail(t *testing.T) { + cases := []struct { + name string + base string + wantParts func(t *testing.T, result string) + }{ + { + name: "standard email", + base: "user@example.com", + wantParts: func(t *testing.T, result string) { + require.Contains(t, result, "@example.com") + require.Contains(t, result, "user+") + }, + }, + { + name: "no @ in base", + base: "useronly", + wantParts: func(t *testing.T, result string) { + require.True(t, strings.HasPrefix(result, "useronly+")) + }, + }, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + result, err := GenerateRandomEmail(c.base) + require.NoError(t, err) + require.NotEmpty(t, result) + c.wantParts(t, result) + + // Each call must produce a different value. + result2, err := GenerateRandomEmail(c.base) + require.NoError(t, err) + require.NotEqual(t, result, result2) + }) + } +} From 3ace46cd28565a4dac80bff9d84453d7292dbb23 Mon Sep 17 00:00:00 2001 From: Juan Fernandez Date: Sat, 11 Apr 2026 14:48:41 -0400 Subject: [PATCH 02/18] Regen schema --- server/datastore/mysql/schema.sql | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/server/datastore/mysql/schema.sql b/server/datastore/mysql/schema.sql index 46bbf7760a7..dc295e92067 100644 --- a/server/datastore/mysql/schema.sql +++ b/server/datastore/mysql/schema.sql @@ -1934,9 +1934,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=514 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +) /*!50100 TABLESPACE `innodb_system` */ ENGINE=InnoDB AUTO_INCREMENT=515 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,20250326161930,1,'2020-01-01 01:01:01'),(370,20250326161931,1,'2020-01-01 01:01:01'),(371,20250331042354,1,'2020-01-01 01:01:01'),(372,20250331154206,1,'2020-01-01 01:01:01'),(373,20250401155831,1,'2020-01-01 01:01:01'),(374,20250408133233,1,'2020-01-01 01:01:01'),(375,20250410104321,1,'2020-01-01 01:01:01'),(376,20250421085116,1,'2020-01-01 01:01:01'),(377,20250422095806,1,'2020-01-01 01:01:01'),(378,20250424153059,1,'2020-01-01 01:01:01'),(379,20250430103833,1,'2020-01-01 01:01:01'),(380,20250430112622,1,'2020-01-01 01:01:01'),(381,20250501162727,1,'2020-01-01 01:01:01'),(382,20250502154517,1,'2020-01-01 01:01:01'),(383,20250502222222,1,'2020-01-01 01:01:01'),(384,20250507170845,1,'2020-01-01 01:01:01'),(385,20250513162912,1,'2020-01-01 01:01:01'),(386,20250519161614,1,'2020-01-01 01:01:01'),(387,20250519170000,1,'2020-01-01 01:01:01'),(388,20250520153848,1,'2020-01-01 01:01:01'),(389,20250528115932,1,'2020-01-01 01:01:01'),(390,20250529102706,1,'2020-01-01 01:01:01'),(391,20250603105558,1,'2020-01-01 01:01:01'),(392,20250609102714,1,'2020-01-01 01:01:01'),(393,20250609112613,1,'2020-01-01 01:01:01'),(394,20250613103810,1,'2020-01-01 01:01:01'),(395,20250616193950,1,'2020-01-01 01:01:01'),(396,20250624140757,1,'2020-01-01 01:01:01'),(397,20250626130239,1,'2020-01-01 01:01:01'),(398,20250629131032,1,'2020-01-01 01:01:01'),(399,20250701155654,1,'2020-01-01 01:01:01'),(400,20250707095725,1,'2020-01-01 01:01:01'),(401,20250716152435,1,'2020-01-01 01:01:01'),(402,20250718091828,1,'2020-01-01 01:01:01'),(403,20250728122229,1,'2020-01-01 01:01:01'),(404,20250731122715,1,'2020-01-01 01:01:01'),(405,20250731151000,1,'2020-01-01 01:01:01'),(406,20250803000000,1,'2020-01-01 01:01:01'),(407,20250805083116,1,'2020-01-01 01:01:01'),(408,20250807140441,1,'2020-01-01 01:01:01'),(409,20250808000000,1,'2020-01-01 01:01:01'),(410,20250811155036,1,'2020-01-01 01:01:01'),(411,20250813205039,1,'2020-01-01 01:01:01'),(412,20250814123333,1,'2020-01-01 01:01:01'),(413,20250815130115,1,'2020-01-01 01:01:01'),(414,20250816115553,1,'2020-01-01 01:01:01'),(415,20250817154557,1,'2020-01-01 01:01:01'),(416,20250825113751,1,'2020-01-01 01:01:01'),(417,20250827113140,1,'2020-01-01 01:01:01'),(418,20250828120836,1,'2020-01-01 01:01:01'),(419,20250902112642,1,'2020-01-01 01:01:01'),(420,20250904091745,1,'2020-01-01 01:01:01'),(421,20250905090000,1,'2020-01-01 01:01:01'),(422,20250922083056,1,'2020-01-01 01:01:01'),(423,20250923120000,1,'2020-01-01 01:01:01'),(424,20250926123048,1,'2020-01-01 01:01:01'),(425,20251015103505,1,'2020-01-01 01:01:01'),(426,20251015103600,1,'2020-01-01 01:01:01'),(427,20251015103700,1,'2020-01-01 01:01:01'),(428,20251015103800,1,'2020-01-01 01:01:01'),(429,20251015103900,1,'2020-01-01 01:01:01'),(430,20251028140000,1,'2020-01-01 01:01:01'),(431,20251028140100,1,'2020-01-01 01:01:01'),(432,20251028140110,1,'2020-01-01 01:01:01'),(433,20251028140200,1,'2020-01-01 01:01:01'),(434,20251028140300,1,'2020-01-01 01:01:01'),(435,20251028140400,1,'2020-01-01 01:01:01'),(436,20251031154558,1,'2020-01-01 01:01:01'),(437,20251103160848,1,'2020-01-01 01:01:01'),(438,20251104112849,1,'2020-01-01 01:01:01'),(439,20251106000000,1,'2020-01-01 01:01:01'),(440,20251107164629,1,'2020-01-01 01:01:01'),(441,20251107170854,1,'2020-01-01 01:01:01'),(442,20251110172137,1,'2020-01-01 01:01:01'),(443,20251111153133,1,'2020-01-01 01:01:01'),(444,20251117020000,1,'2020-01-01 01:01:01'),(445,20251117020100,1,'2020-01-01 01:01:01'),(446,20251117020200,1,'2020-01-01 01:01:01'),(447,20251121100000,1,'2020-01-01 01:01:01'),(448,20251121124239,1,'2020-01-01 01:01:01'),(449,20251124090450,1,'2020-01-01 01:01:01'),(450,20251124135808,1,'2020-01-01 01:01:01'),(451,20251124140138,1,'2020-01-01 01:01:01'),(452,20251124162948,1,'2020-01-01 01:01:01'),(453,20251127113559,1,'2020-01-01 01:01:01'),(454,20251202162232,1,'2020-01-01 01:01:01'),(455,20251203170808,1,'2020-01-01 01:01:01'),(456,20251207050413,1,'2020-01-01 01:01:01'),(457,20251208215800,1,'2020-01-01 01:01:01'),(458,20251209221730,1,'2020-01-01 01:01:01'),(459,20251209221850,1,'2020-01-01 01:01:01'),(460,20251215163721,1,'2020-01-01 01:01:01'),(461,20251217000000,1,'2020-01-01 01:01:01'),(462,20251217120000,1,'2020-01-01 01:01:01'),(463,20251229000000,1,'2020-01-01 01:01:01'),(464,20251229000010,1,'2020-01-01 01:01:01'),(465,20251229000020,1,'2020-01-01 01:01:01'),(466,20260106000000,1,'2020-01-01 01:01:01'),(467,20260108200708,1,'2020-01-01 01:01:01'),(468,20260108214732,1,'2020-01-01 01:01:01'),(469,20260109231821,1,'2020-01-01 01:01:01'),(470,20260113012054,1,'2020-01-01 01:01:01'),(471,20260124200020,1,'2020-01-01 01:01:01'),(472,20260126150840,1,'2020-01-01 01:01:01'),(473,20260126210724,1,'2020-01-01 01:01:01'),(474,20260202151756,1,'2020-01-01 01:01:01'),(475,20260205184907,1,'2020-01-01 01:01:01'),(476,20260210151544,1,'2020-01-01 01:01:01'),(477,20260210155109,1,'2020-01-01 01:01:01'),(478,20260210181120,1,'2020-01-01 01:01:01'),(479,20260211200153,1,'2020-01-01 01:01:01'),(480,20260217141240,1,'2020-01-01 01:01:01'),(481,20260217200906,1,'2020-01-01 01:01:01'),(482,20260218175704,1,'2020-01-01 01:01:01'),(483,20260314120000,1,'2020-01-01 01:01:01'),(484,20260316120000,1,'2020-01-01 01:01:01'),(485,20260316120001,1,'2020-01-01 01:01:01'),(486,20260316120002,1,'2020-01-01 01:01:01'),(487,20260316120003,1,'2020-01-01 01:01:01'),(488,20260316120004,1,'2020-01-01 01:01:01'),(489,20260316120005,1,'2020-01-01 01:01:01'),(490,20260316120006,1,'2020-01-01 01:01:01'),(491,20260316120007,1,'2020-01-01 01:01:01'),(492,20260316120008,1,'2020-01-01 01:01:01'),(493,20260316120009,1,'2020-01-01 01:01:01'),(494,20260316120010,1,'2020-01-01 01:01:01'),(495,20260317120000,1,'2020-01-01 01:01:01'),(496,20260318184559,1,'2020-01-01 01:01:01'),(497,20260319120000,1,'2020-01-01 01:01:01'),(498,20260323144117,1,'2020-01-01 01:01:01'),(499,20260324161944,1,'2020-01-01 01:01:01'),(500,20260324223334,1,'2020-01-01 01:01:01'),(501,20260326131501,1,'2020-01-01 01:01:01'),(502,20260326210603,1,'2020-01-01 01:01:01'),(503,20260331000000,1,'2020-01-01 01:01:01'),(504,20260401153000,1,'2020-01-01 01:01:01'),(505,20260401153001,1,'2020-01-01 01:01:01'),(506,20260401153503,1,'2020-01-01 01:01:01'),(507,20260403120000,1,'2020-01-01 01:01:01'),(508,20260409153713,1,'2020-01-01 01:01:01'),(509,20260409153714,1,'2020-01-01 01:01:01'),(510,20260409153715,1,'2020-01-01 01:01:01'),(511,20260409153716,1,'2020-01-01 01:01:01'),(512,20260409153717,1,'2020-01-01 01:01:01'),(513,20260409183610,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,20250326161930,1,'2020-01-01 01:01:01'),(370,20250326161931,1,'2020-01-01 01:01:01'),(371,20250331042354,1,'2020-01-01 01:01:01'),(372,20250331154206,1,'2020-01-01 01:01:01'),(373,20250401155831,1,'2020-01-01 01:01:01'),(374,20250408133233,1,'2020-01-01 01:01:01'),(375,20250410104321,1,'2020-01-01 01:01:01'),(376,20250421085116,1,'2020-01-01 01:01:01'),(377,20250422095806,1,'2020-01-01 01:01:01'),(378,20250424153059,1,'2020-01-01 01:01:01'),(379,20250430103833,1,'2020-01-01 01:01:01'),(380,20250430112622,1,'2020-01-01 01:01:01'),(381,20250501162727,1,'2020-01-01 01:01:01'),(382,20250502154517,1,'2020-01-01 01:01:01'),(383,20250502222222,1,'2020-01-01 01:01:01'),(384,20250507170845,1,'2020-01-01 01:01:01'),(385,20250513162912,1,'2020-01-01 01:01:01'),(386,20250519161614,1,'2020-01-01 01:01:01'),(387,20250519170000,1,'2020-01-01 01:01:01'),(388,20250520153848,1,'2020-01-01 01:01:01'),(389,20250528115932,1,'2020-01-01 01:01:01'),(390,20250529102706,1,'2020-01-01 01:01:01'),(391,20250603105558,1,'2020-01-01 01:01:01'),(392,20250609102714,1,'2020-01-01 01:01:01'),(393,20250609112613,1,'2020-01-01 01:01:01'),(394,20250613103810,1,'2020-01-01 01:01:01'),(395,20250616193950,1,'2020-01-01 01:01:01'),(396,20250624140757,1,'2020-01-01 01:01:01'),(397,20250626130239,1,'2020-01-01 01:01:01'),(398,20250629131032,1,'2020-01-01 01:01:01'),(399,20250701155654,1,'2020-01-01 01:01:01'),(400,20250707095725,1,'2020-01-01 01:01:01'),(401,20250716152435,1,'2020-01-01 01:01:01'),(402,20250718091828,1,'2020-01-01 01:01:01'),(403,20250728122229,1,'2020-01-01 01:01:01'),(404,20250731122715,1,'2020-01-01 01:01:01'),(405,20250731151000,1,'2020-01-01 01:01:01'),(406,20250803000000,1,'2020-01-01 01:01:01'),(407,20250805083116,1,'2020-01-01 01:01:01'),(408,20250807140441,1,'2020-01-01 01:01:01'),(409,20250808000000,1,'2020-01-01 01:01:01'),(410,20250811155036,1,'2020-01-01 01:01:01'),(411,20250813205039,1,'2020-01-01 01:01:01'),(412,20250814123333,1,'2020-01-01 01:01:01'),(413,20250815130115,1,'2020-01-01 01:01:01'),(414,20250816115553,1,'2020-01-01 01:01:01'),(415,20250817154557,1,'2020-01-01 01:01:01'),(416,20250825113751,1,'2020-01-01 01:01:01'),(417,20250827113140,1,'2020-01-01 01:01:01'),(418,20250828120836,1,'2020-01-01 01:01:01'),(419,20250902112642,1,'2020-01-01 01:01:01'),(420,20250904091745,1,'2020-01-01 01:01:01'),(421,20250905090000,1,'2020-01-01 01:01:01'),(422,20250922083056,1,'2020-01-01 01:01:01'),(423,20250923120000,1,'2020-01-01 01:01:01'),(424,20250926123048,1,'2020-01-01 01:01:01'),(425,20251015103505,1,'2020-01-01 01:01:01'),(426,20251015103600,1,'2020-01-01 01:01:01'),(427,20251015103700,1,'2020-01-01 01:01:01'),(428,20251015103800,1,'2020-01-01 01:01:01'),(429,20251015103900,1,'2020-01-01 01:01:01'),(430,20251028140000,1,'2020-01-01 01:01:01'),(431,20251028140100,1,'2020-01-01 01:01:01'),(432,20251028140110,1,'2020-01-01 01:01:01'),(433,20251028140200,1,'2020-01-01 01:01:01'),(434,20251028140300,1,'2020-01-01 01:01:01'),(435,20251028140400,1,'2020-01-01 01:01:01'),(436,20251031154558,1,'2020-01-01 01:01:01'),(437,20251103160848,1,'2020-01-01 01:01:01'),(438,20251104112849,1,'2020-01-01 01:01:01'),(439,20251106000000,1,'2020-01-01 01:01:01'),(440,20251107164629,1,'2020-01-01 01:01:01'),(441,20251107170854,1,'2020-01-01 01:01:01'),(442,20251110172137,1,'2020-01-01 01:01:01'),(443,20251111153133,1,'2020-01-01 01:01:01'),(444,20251117020000,1,'2020-01-01 01:01:01'),(445,20251117020100,1,'2020-01-01 01:01:01'),(446,20251117020200,1,'2020-01-01 01:01:01'),(447,20251121100000,1,'2020-01-01 01:01:01'),(448,20251121124239,1,'2020-01-01 01:01:01'),(449,20251124090450,1,'2020-01-01 01:01:01'),(450,20251124135808,1,'2020-01-01 01:01:01'),(451,20251124140138,1,'2020-01-01 01:01:01'),(452,20251124162948,1,'2020-01-01 01:01:01'),(453,20251127113559,1,'2020-01-01 01:01:01'),(454,20251202162232,1,'2020-01-01 01:01:01'),(455,20251203170808,1,'2020-01-01 01:01:01'),(456,20251207050413,1,'2020-01-01 01:01:01'),(457,20251208215800,1,'2020-01-01 01:01:01'),(458,20251209221730,1,'2020-01-01 01:01:01'),(459,20251209221850,1,'2020-01-01 01:01:01'),(460,20251215163721,1,'2020-01-01 01:01:01'),(461,20251217000000,1,'2020-01-01 01:01:01'),(462,20251217120000,1,'2020-01-01 01:01:01'),(463,20251229000000,1,'2020-01-01 01:01:01'),(464,20251229000010,1,'2020-01-01 01:01:01'),(465,20251229000020,1,'2020-01-01 01:01:01'),(466,20260106000000,1,'2020-01-01 01:01:01'),(467,20260108200708,1,'2020-01-01 01:01:01'),(468,20260108214732,1,'2020-01-01 01:01:01'),(469,20260109231821,1,'2020-01-01 01:01:01'),(470,20260113012054,1,'2020-01-01 01:01:01'),(471,20260124200020,1,'2020-01-01 01:01:01'),(472,20260126150840,1,'2020-01-01 01:01:01'),(473,20260126210724,1,'2020-01-01 01:01:01'),(474,20260202151756,1,'2020-01-01 01:01:01'),(475,20260205184907,1,'2020-01-01 01:01:01'),(476,20260210151544,1,'2020-01-01 01:01:01'),(477,20260210155109,1,'2020-01-01 01:01:01'),(478,20260210181120,1,'2020-01-01 01:01:01'),(479,20260211200153,1,'2020-01-01 01:01:01'),(480,20260217141240,1,'2020-01-01 01:01:01'),(481,20260217200906,1,'2020-01-01 01:01:01'),(482,20260218175704,1,'2020-01-01 01:01:01'),(483,20260314120000,1,'2020-01-01 01:01:01'),(484,20260316120000,1,'2020-01-01 01:01:01'),(485,20260316120001,1,'2020-01-01 01:01:01'),(486,20260316120002,1,'2020-01-01 01:01:01'),(487,20260316120003,1,'2020-01-01 01:01:01'),(488,20260316120004,1,'2020-01-01 01:01:01'),(489,20260316120005,1,'2020-01-01 01:01:01'),(490,20260316120006,1,'2020-01-01 01:01:01'),(491,20260316120007,1,'2020-01-01 01:01:01'),(492,20260316120008,1,'2020-01-01 01:01:01'),(493,20260316120009,1,'2020-01-01 01:01:01'),(494,20260316120010,1,'2020-01-01 01:01:01'),(495,20260317120000,1,'2020-01-01 01:01:01'),(496,20260318184559,1,'2020-01-01 01:01:01'),(497,20260319120000,1,'2020-01-01 01:01:01'),(498,20260323144117,1,'2020-01-01 01:01:01'),(499,20260324161944,1,'2020-01-01 01:01:01'),(500,20260324223334,1,'2020-01-01 01:01:01'),(501,20260326131501,1,'2020-01-01 01:01:01'),(502,20260326210603,1,'2020-01-01 01:01:01'),(503,20260331000000,1,'2020-01-01 01:01:01'),(504,20260401153000,1,'2020-01-01 01:01:01'),(505,20260401153001,1,'2020-01-01 01:01:01'),(506,20260401153503,1,'2020-01-01 01:01:01'),(507,20260403120000,1,'2020-01-01 01:01:01'),(508,20260409153713,1,'2020-01-01 01:01:01'),(509,20260409153714,1,'2020-01-01 01:01:01'),(510,20260409153715,1,'2020-01-01 01:01:01'),(511,20260409153716,1,'2020-01-01 01:01:01'),(512,20260409153717,1,'2020-01-01 01:01:01'),(513,20260409183610,1,'2020-01-01 01:01:01'),(514,20260411000000,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` ( @@ -3000,12 +3000,9 @@ CREATE TABLE `user_api_endpoints` ( `path` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, `method` varchar(10) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, - `author_id` int unsigned DEFAULT NULL, PRIMARY KEY (`user_id`,`path`,`method`), - KEY `author_id` (`author_id`), - CONSTRAINT `user_api_endpoints_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE, - CONSTRAINT `user_api_endpoints_ibfk_2` FOREIGN KEY (`author_id`) REFERENCES `users` (`id`) ON DELETE SET NULL -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + CONSTRAINT `user_api_endpoints_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE +) /*!50100 TABLESPACE `innodb_system` */ 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 */; From ec76cad32aab6ad70f6876f208a2611e38d65bda Mon Sep 17 00:00:00 2001 From: Juan Fernandez Date: Sat, 11 Apr 2026 15:29:13 -0400 Subject: [PATCH 03/18] Fixed test --- server/service/integration_enterprise_test.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/server/service/integration_enterprise_test.go b/server/service/integration_enterprise_test.go index 3fb4d8f43a1..f4f48b8fb66 100644 --- a/server/service/integration_enterprise_test.go +++ b/server/service/integration_enterprise_test.go @@ -28466,6 +28466,9 @@ func (s *integrationEnterpriseTestSuite) TestCreateAPIOnlyUserPremium() { s.DoJSON("POST", "/api/latest/fleet/users/api_only", map[string]any{ "name": "Team API User", "fleets": []map[string]any{{"id": team.ID, "role": "observer"}}, + "api_endpoints": []map[string]any{ + {"method": "*", "path": "*"}, + }, }, http.StatusOK, &createRespTeam) require.NotEmpty(t, createRespTeam.Token) From 8b3360f60efb96e73e4679f25fbf95b23bde7857 Mon Sep 17 00:00:00 2001 From: Juan Fernandez Date: Sat, 11 Apr 2026 17:07:14 -0400 Subject: [PATCH 04/18] PR feedback --- ...-42880-42884-allow-creation-of-api-only-users | 1 + cmd/fleetctl/fleetctl/user.go | 6 ++++++ ...411000000_DropAuthorIdFromUserApiEndpoints.go | 4 +++- server/fleet/service.go | 3 +-- server/service/integration_enterprise_test.go | 16 +++++++++++++--- server/service/users.go | 15 ++++++++++++--- 6 files changed, 36 insertions(+), 9 deletions(-) diff --git a/changes/42882-42880-42884-allow-creation-of-api-only-users b/changes/42882-42880-42884-allow-creation-of-api-only-users index f93a76ef622..7de718ec591 100644 --- a/changes/42882-42880-42884-allow-creation-of-api-only-users +++ b/changes/42882-42880-42884-allow-creation-of-api-only-users @@ -1,3 +1,4 @@ - Added POST /users/api_only endpoint for creating API-only users. - Added PATCH /users/api_only/{id} for updating existing API-only users. +- Updated GET /users/{id} response to include the new `api_endpoints` field for API-only users. - Updated `fleetctl user create --api-only` removing email/password field requirements. diff --git a/cmd/fleetctl/fleetctl/user.go b/cmd/fleetctl/fleetctl/user.go index e83c992cfc5..112027ddb8b 100644 --- a/cmd/fleetctl/fleetctl/user.go +++ b/cmd/fleetctl/fleetctl/user.go @@ -125,6 +125,12 @@ func createUserCommand() *cli.Command { } if apiOnly { + if sso { + return errors.New("--sso cannot be used with --api-only") + } + if mfa { + return errors.New("--mfa cannot be used with --api-only") + } sessionKey, err := client.CreateAPIOnlyUser(name, globalRole, teams) if err != nil { return fmt.Errorf("Failed to create user: %w", err) diff --git a/server/datastore/mysql/migrations/tables/20260411000000_DropAuthorIdFromUserApiEndpoints.go b/server/datastore/mysql/migrations/tables/20260411000000_DropAuthorIdFromUserApiEndpoints.go index 6863598fc5f..86509ca4c86 100644 --- a/server/datastore/mysql/migrations/tables/20260411000000_DropAuthorIdFromUserApiEndpoints.go +++ b/server/datastore/mysql/migrations/tables/20260411000000_DropAuthorIdFromUserApiEndpoints.go @@ -3,6 +3,7 @@ package tables import ( "database/sql" "fmt" + "strings" ) func init() { @@ -25,8 +26,9 @@ func Up_20260411000000(tx *sql.Tx) error { } if constraintName != "" { + escaped := strings.ReplaceAll(constraintName, "`", "``") if _, err := tx.Exec(fmt.Sprintf( - `ALTER TABLE user_api_endpoints DROP FOREIGN KEY %s`, constraintName, + "ALTER TABLE user_api_endpoints DROP FOREIGN KEY `%s`", escaped, )); err != nil { return fmt.Errorf("drop author_id foreign key: %w", err) } diff --git a/server/fleet/service.go b/server/fleet/service.go index ff8e6e55a51..044821fdb1b 100644 --- a/server/fleet/service.go +++ b/server/fleet/service.go @@ -195,8 +195,7 @@ type Service interface { // ModifyUser updates a user's parameters given a UserPayload. ModifyUser(ctx context.Context, userID uint, p UserPayload) (user *User, err error) - // ModifyAPIOnlyUser patches the allowed properties of an existing API-only user. - // Only name, global_role, teams and api_endpoints may be changed. + // ModifyAPIOnlyUser updates an API-only user ModifyAPIOnlyUser(ctx context.Context, userID uint, p UserPayload) (user *User, err error) // DeleteUser permanently deletes the user identified by the provided ID. diff --git a/server/service/integration_enterprise_test.go b/server/service/integration_enterprise_test.go index f4f48b8fb66..3ab0c272ad6 100644 --- a/server/service/integration_enterprise_test.go +++ b/server/service/integration_enterprise_test.go @@ -28588,7 +28588,7 @@ func (s *integrationEnterpriseTestSuite) TestPatchAPIOnlyUserPremium() { "fleets": []map[string]any{{"id": team.ID, "role": "observer"}}, }, http.StatusUnprocessableEntity) - // PATCH updating fleets (team role) → 200. + // PATCH updating fleets (team role) → 200; global_role must be cleared. var respTeam patchResp s.DoJSON("PATCH", patchURL, map[string]any{ "fleets": []map[string]any{{"id": team.ID, "role": "observer"}}, @@ -28596,6 +28596,7 @@ func (s *integrationEnterpriseTestSuite) TestPatchAPIOnlyUserPremium() { require.Len(t, respTeam.User.Teams, 1) require.Equal(t, team.ID, respTeam.User.Teams[0].ID) require.Equal(t, "observer", respTeam.User.Teams[0].Role) + require.Nil(t, respTeam.User.GlobalRole, "global_role should be cleared when switching to fleet role") // PATCH updating api_endpoints with catalog entries → 200. var respEndpoints patchResp @@ -28673,9 +28674,18 @@ func (s *integrationEnterpriseTestSuite) TestGetUserReturnsAPIEndpoints() { require.Len(t, found.APIEndpoints, 2) // A regular (non-API-only) user should have no api_endpoints in either response. - adminID := uint(1) // admin1@example.com created in suite setup + // Create a fresh regular user rather than relying on seed data. + var regularUserResp getUserResp + s.DoJSON("POST", "/api/latest/fleet/users/admin", fleet.UserPayload{ + Name: ptr.String("Regular User"), + Email: ptr.String("regular-user-get-test@example.com"), + Password: ptr.String(test.GoodPassword), + GlobalRole: ptr.String(fleet.RoleObserver), + }, http.StatusOK, ®ularUserResp) + require.NotZero(t, regularUserResp.User.ID) + var getAdminResp getUserResp - s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/users/%d", adminID), nil, http.StatusOK, &getAdminResp) + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/users/%d", regularUserResp.User.ID), nil, http.StatusOK, &getAdminResp) require.False(t, getAdminResp.User.APIOnly) require.Empty(t, getAdminResp.User.APIEndpoints) diff --git a/server/service/users.go b/server/service/users.go index 56ef82bf39f..91f023eff48 100644 --- a/server/service/users.go +++ b/server/service/users.go @@ -198,6 +198,9 @@ func (svc *Service) CreateUser(ctx context.Context, p fleet.UserPayload) (*fleet } } + if p.APIEndpoints != nil && (p.APIOnly == nil || !*p.APIOnly) { + return nil, nil, ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("api_endpoints", "API endpoints can only be specified for API only users")) + } if p.APIOnly != nil && *p.APIOnly { // API-Endpoints is a premium only feature, // so we only require it if creating an API only @@ -248,6 +251,7 @@ func createAPIOnlyUserEndpoint(ctx context.Context, request any, svc fleet.Servi pwd, err := server.GenerateRandomPwd() if err != nil { + setAuthCheckedOnPreAuthErr(ctx) return createUserResponse{ Err: ctxerr.Wrap(ctx, err, "generate user password"), }, nil @@ -257,12 +261,14 @@ func createAPIOnlyUserEndpoint(ctx context.Context, request any, svc fleet.Servi // so we should have a logged-in user at this point. vc, ok := viewer.FromContext(ctx) if !ok { + setAuthCheckedOnPreAuthErr(ctx) return createUserResponse{ Err: ctxerr.New(ctx, "failed to get logged user"), }, nil } email, err := server.GenerateRandomEmail(vc.Email()) if err != nil { + setAuthCheckedOnPreAuthErr(ctx) return createUserResponse{ Err: ctxerr.Wrap(ctx, err, "generate user email"), }, nil @@ -323,9 +329,12 @@ func modifyAPIOnlyUserEndpoint(ctx context.Context, request any, svc fleet.Servi } func (svc *Service) ModifyAPIOnlyUser(ctx context.Context, userID uint, p fleet.UserPayload) (*fleet.User, error) { - // Authorize before hitting the DB so that callers without write access cannot - // enumerate user IDs by observing 404 vs non-404 responses. - if err := svc.authz.Authorize(ctx, &fleet.User{}, fleet.ActionWrite); err != nil { + target, err := svc.ds.UserByID(ctx, userID) + if err != nil { + setAuthCheckedOnPreAuthErr(ctx) + return nil, fleet.NewPermissionError("no permission to modify this user") + } + if err := svc.authz.Authorize(ctx, target, fleet.ActionWrite); err != nil { return nil, err } From fa1415cd6c3c4aedcf906fcb3816b96ff58882aa Mon Sep 17 00:00:00 2001 From: Juan Fernandez Date: Sun, 12 Apr 2026 16:11:50 -0400 Subject: [PATCH 05/18] No not return 403 if user not found --- cmd/fleetctl/fleetctl/user.go | 3 --- server/api_endpoints/api_endpoints.go | 4 ++-- server/service/integration_core_test.go | 8 +------- server/service/integration_enterprise_test.go | 17 +---------------- server/service/users.go | 2 +- 5 files changed, 5 insertions(+), 29 deletions(-) diff --git a/cmd/fleetctl/fleetctl/user.go b/cmd/fleetctl/fleetctl/user.go index 112027ddb8b..d649da1d28a 100644 --- a/cmd/fleetctl/fleetctl/user.go +++ b/cmd/fleetctl/fleetctl/user.go @@ -125,9 +125,6 @@ func createUserCommand() *cli.Command { } if apiOnly { - if sso { - return errors.New("--sso cannot be used with --api-only") - } if mfa { return errors.New("--mfa cannot be used with --api-only") } diff --git a/server/api_endpoints/api_endpoints.go b/server/api_endpoints/api_endpoints.go index b9d7153f687..30c857c61a2 100644 --- a/server/api_endpoints/api_endpoints.go +++ b/server/api_endpoints/api_endpoints.go @@ -45,7 +45,7 @@ func Init(h http.Handler) error { return nil }) - loadedApiEndpoints, err := loadGetAPIEndpoints() + loadedApiEndpoints, err := loadAPIEndpoints() if err != nil { return err } @@ -66,7 +66,7 @@ func Init(h http.Handler) error { return nil } -func loadGetAPIEndpoints() ([]fleet.APIEndpoint, error) { +func loadAPIEndpoints() ([]fleet.APIEndpoint, error) { endpoints := make([]fleet.APIEndpoint, 0) if err := yaml.Unmarshal(apiEndpointsYAML, &endpoints); err != nil { diff --git a/server/service/integration_core_test.go b/server/service/integration_core_test.go index dd4586b2bef..85e4266234d 100644 --- a/server/service/integration_core_test.go +++ b/server/service/integration_core_test.go @@ -395,10 +395,9 @@ func (s *integrationTestSuite) TestCreateAPIOnlyUser() { require.Equal(t, "observer", *createResp.User.GlobalRole) } -func (s *integrationTestSuite) TestPatchAPIOnlyUser() { +func (s *integrationTestSuite) TestModifyAPIOnlyUser() { t := s.T() - // Create an API-only user on the free tier (global_role only, no premium features). var createResp struct { User struct { ID uint `json:"id"` @@ -412,22 +411,17 @@ func (s *integrationTestSuite) TestPatchAPIOnlyUser() { require.NotZero(t, createResp.User.ID) apiUserID := createResp.User.ID - // Unauthenticated PATCH → 401. - // Must send a valid JSON body ({}) to avoid pre-auth decode error (400 vs 401). s.DoRawNoAuth("PATCH", fmt.Sprintf("/api/latest/fleet/users/api_only/%d", apiUserID), []byte(`{}`), http.StatusUnauthorized) - // PATCH non-existent user → 404. s.Do("PATCH", "/api/latest/fleet/users/api_only/999999", map[string]any{ "name": "New Name", }, http.StatusNotFound) - // PATCH a non-API-only user (admin1) → 422. admin := s.users["admin1@example.com"] s.Do("PATCH", fmt.Sprintf("/api/latest/fleet/users/api_only/%d", admin.ID), map[string]any{ "name": "New Name", }, http.StatusUnprocessableEntity) - // PATCH api_endpoints without premium → 402. s.Do("PATCH", fmt.Sprintf("/api/latest/fleet/users/api_only/%d", apiUserID), map[string]any{ "api_endpoints": []map[string]any{ {"method": "GET", "path": "/api/v1/fleet/config"}, diff --git a/server/service/integration_enterprise_test.go b/server/service/integration_enterprise_test.go index 3ab0c272ad6..68057593385 100644 --- a/server/service/integration_enterprise_test.go +++ b/server/service/integration_enterprise_test.go @@ -28481,14 +28481,12 @@ func (s *integrationEnterpriseTestSuite) TestCreateAPIOnlyUserPremium() { require.Equal(t, "observer", createRespTeam.User.Teams[0].Role) } -func (s *integrationEnterpriseTestSuite) TestPatchAPIOnlyUserPremium() { +func (s *integrationEnterpriseTestSuite) TestModifyAPIOnlyUserPremium() { t := s.T() - // Create a team for fleet-scoped assignments. team, err := s.ds.NewTeam(context.Background(), &fleet.Team{Name: t.Name() + "_team"}) require.NoError(t, err) - // Create a non-API-only user to verify the endpoint rejects it. nonAPIUser := &fleet.User{ Name: "Non API User", Email: "non-api-patch@example.com", @@ -28498,7 +28496,6 @@ func (s *integrationEnterpriseTestSuite) TestPatchAPIOnlyUserPremium() { nonAPIUser, err = s.ds.NewUser(context.Background(), nonAPIUser) require.NoError(t, err) - // Helper struct for parsing PATCH responses. type patchResp struct { User struct { ID uint `json:"id"` @@ -28518,7 +28515,6 @@ func (s *integrationEnterpriseTestSuite) TestPatchAPIOnlyUserPremium() { Err string `json:"error,omitempty"` } - // Create the API-only user we'll be patching throughout the test. var createResp struct { User struct { ID uint `json:"id"` @@ -28536,24 +28532,20 @@ func (s *integrationEnterpriseTestSuite) TestPatchAPIOnlyUserPremium() { uid := createResp.User.ID patchURL := fmt.Sprintf("/api/latest/fleet/users/api_only/%d", uid) - // PATCH non-existent user → 404. s.Do("PATCH", "/api/latest/fleet/users/api_only/999999", map[string]any{ "name": "Ghost", }, http.StatusNotFound) - // PATCH a non-API-only user → 422. s.Do("PATCH", fmt.Sprintf("/api/latest/fleet/users/api_only/%d", nonAPIUser.ID), map[string]any{ "name": "New Name", }, http.StatusUnprocessableEntity) - // PATCH with invalid api_endpoint (not in catalog) → 422. s.Do("PATCH", patchURL, map[string]any{ "api_endpoints": []map[string]any{ {"method": "GET", "path": "/api/v1/fleet/nonexistent/endpoint"}, }, }, http.StatusUnprocessableEntity) - // PATCH with wildcard mixed with other entries → 422. s.Do("PATCH", patchURL, map[string]any{ "api_endpoints": []map[string]any{ {"method": "*", "path": "*"}, @@ -28561,12 +28553,10 @@ func (s *integrationEnterpriseTestSuite) TestPatchAPIOnlyUserPremium() { }, }, http.StatusUnprocessableEntity) - // PATCH with empty api_endpoints slice → 422. s.Do("PATCH", patchURL, map[string]any{ "api_endpoints": []map[string]any{}, }, http.StatusUnprocessableEntity) - // PATCH updating name only → 200. var respName patchResp s.DoJSON("PATCH", patchURL, map[string]any{ "name": "Patched Name", @@ -28574,7 +28564,6 @@ func (s *integrationEnterpriseTestSuite) TestPatchAPIOnlyUserPremium() { require.Equal(t, "Patched Name", respName.User.Name) require.True(t, respName.User.APIOnly) - // PATCH updating global_role → 200. var respRole patchResp s.DoJSON("PATCH", patchURL, map[string]any{ "global_role": "admin", @@ -28582,13 +28571,11 @@ func (s *integrationEnterpriseTestSuite) TestPatchAPIOnlyUserPremium() { require.NotNil(t, respRole.User.GlobalRole) require.Equal(t, "admin", *respRole.User.GlobalRole) - // PATCH global_role AND fleets together → 422 (mutual exclusivity). s.Do("PATCH", patchURL, map[string]any{ "global_role": "observer", "fleets": []map[string]any{{"id": team.ID, "role": "observer"}}, }, http.StatusUnprocessableEntity) - // PATCH updating fleets (team role) → 200; global_role must be cleared. var respTeam patchResp s.DoJSON("PATCH", patchURL, map[string]any{ "fleets": []map[string]any{{"id": team.ID, "role": "observer"}}, @@ -28598,7 +28585,6 @@ func (s *integrationEnterpriseTestSuite) TestPatchAPIOnlyUserPremium() { require.Equal(t, "observer", respTeam.User.Teams[0].Role) require.Nil(t, respTeam.User.GlobalRole, "global_role should be cleared when switching to fleet role") - // PATCH updating api_endpoints with catalog entries → 200. var respEndpoints patchResp s.DoJSON("PATCH", patchURL, map[string]any{ "api_endpoints": []map[string]any{ @@ -28608,7 +28594,6 @@ func (s *integrationEnterpriseTestSuite) TestPatchAPIOnlyUserPremium() { }, http.StatusOK, &respEndpoints) require.Len(t, respEndpoints.User.APIEndpoints, 2) - // PATCH updating api_endpoints to single wildcard → 200. var respWildcard patchResp s.DoJSON("PATCH", patchURL, map[string]any{ "api_endpoints": []map[string]any{ diff --git a/server/service/users.go b/server/service/users.go index 91f023eff48..114b9bce663 100644 --- a/server/service/users.go +++ b/server/service/users.go @@ -332,7 +332,7 @@ func (svc *Service) ModifyAPIOnlyUser(ctx context.Context, userID uint, p fleet. target, err := svc.ds.UserByID(ctx, userID) if err != nil { setAuthCheckedOnPreAuthErr(ctx) - return nil, fleet.NewPermissionError("no permission to modify this user") + return nil, ctxerr.Wrap(ctx, err) } if err := svc.authz.Authorize(ctx, target, fleet.ActionWrite); err != nil { return nil, err From 9b72dbdd6f18103cd4bf2e5f6104b51661fc1a7a Mon Sep 17 00:00:00 2001 From: Juan Fernandez Date: Mon, 13 Apr 2026 07:05:45 -0400 Subject: [PATCH 06/18] Refactored tests --- server/datastore/mysql/users.go | 5 +- server/service/client_users.go | 4 +- server/service/integration_core_test.go | 117 +++-- server/service/integration_enterprise_test.go | 469 ++++++++++-------- 4 files changed, 346 insertions(+), 249 deletions(-) diff --git a/server/datastore/mysql/users.go b/server/datastore/mysql/users.go index a24f6572490..ef0cab04444 100644 --- a/server/datastore/mysql/users.go +++ b/server/datastore/mysql/users.go @@ -325,7 +325,6 @@ func saveUserDB(ctx context.Context, tx sqlx.ExtContext, user *fleet.User) error } // loadAPIEndpointsForUsers loads api_endpoints for any API-only users in the slice. -// Non-API-only users are left untouched. func (ds *Datastore) loadAPIEndpointsForUsers(ctx context.Context, users []*fleet.User) error { var apiOnlyIDs []uint for _, u := range users { @@ -363,7 +362,7 @@ func (ds *Datastore) loadAPIEndpointsForUsers(ctx context.Context, users []*flee } for _, u := range users { if u.APIOnly { - u.APIEndpoints = byUserID[u.ID] // nil when no endpoints assigned + u.APIEndpoints = byUserID[u.ID] } } return nil @@ -582,7 +581,7 @@ func replaceUserAPIEndpoints(ctx context.Context, tx sqlx.ExtContext, userID uin return nil } placeholders := strings.Repeat("(?, ?, ?),", len(endpoints)) - placeholders = placeholders[:len(placeholders)-1] // trim trailing comma + placeholders = placeholders[:len(placeholders)-1] args := make([]any, 0, len(endpoints)*3) for _, ep := range endpoints { args = append(args, userID, ep.Path, ep.Method) diff --git a/server/service/client_users.go b/server/service/client_users.go index 0a7b116882b..2a6947e4089 100644 --- a/server/service/client_users.go +++ b/server/service/client_users.go @@ -7,9 +7,7 @@ import ( "github.com/fleetdm/fleet/v4/server/fleet" ) -// CreateAPIOnlyUser creates a new API-only user via the dedicated endpoint. -// The email and password are generated server-side. -// Returns the API token for the new user. +// CreateAPIOnlyUser creates a new API-only user, returning the API token for the new user. func (c *Client) CreateAPIOnlyUser(name string, globalRole *string, teams []fleet.UserTeam) (*string, error) { verb, path := "POST", "/api/latest/fleet/users/api_only" payload := fleet.UserPayload{ diff --git a/server/service/integration_core_test.go b/server/service/integration_core_test.go index 85e4266234d..6eb5fa0087d 100644 --- a/server/service/integration_core_test.go +++ b/server/service/integration_core_test.go @@ -336,40 +336,7 @@ func (s *integrationTestSuite) TestUserCreationWrongTeamErrors() { func (s *integrationTestSuite) TestCreateAPIOnlyUser() { t := s.T() - // missing name → 422 - s.Do("POST", "/api/latest/fleet/users/api_only", map[string]any{ - "global_role": "observer", - }, http.StatusUnprocessableEntity) - - // neither global_role nor fleets → 422 - s.Do("POST", "/api/latest/fleet/users/api_only", map[string]any{ - "name": "Jane Doe", - }, http.StatusUnprocessableEntity) - - // fleets without premium → 402 - s.Do("POST", "/api/latest/fleet/users/api_only", map[string]any{ - "name": "Jane Doe", - "fleets": []map[string]any{{"id": 9999, "role": "observer"}}, - }, http.StatusPaymentRequired) - - // api_endpoints without premium → 402 - s.Do("POST", "/api/latest/fleet/users/api_only", map[string]any{ - "name": "Jane Doe", - "global_role": "observer", - "api_endpoints": []map[string]any{ - {"method": "GET", "path": "/api/v1/fleet/hosts/:id"}, - }, - }, http.StatusPaymentRequired) - - // both global_role and fleets without premium → 402 (premium check fires first) - s.Do("POST", "/api/latest/fleet/users/api_only", map[string]any{ - "name": "Jane Doe", - "global_role": "observer", - "fleets": []map[string]any{{"id": 9999, "role": "observer"}}, - }, http.StatusPaymentRequired) - - // successful creation with global_role only (no premium features) → 200 - var createResp struct { + type createAPIOnlyUserResponse struct { User struct { ID uint `json:"id"` Name string `json:"name"` @@ -381,18 +348,78 @@ func (s *integrationTestSuite) TestCreateAPIOnlyUser() { Err string `json:"error,omitempty"` } - s.DoJSON("POST", "/api/latest/fleet/users/api_only", map[string]any{ - "name": "Jane Doe", - "global_role": "observer", - }, http.StatusOK, &createResp) + cases := []struct { + name string + body map[string]any + wantStatus int + verify func(t *testing.T, resp createAPIOnlyUserResponse) + }{ + { + name: "missing name", + body: map[string]any{"global_role": "observer"}, + wantStatus: http.StatusUnprocessableEntity, + }, + { + name: "neither global_role nor fleets", + body: map[string]any{"name": "Jane Doe"}, + wantStatus: http.StatusUnprocessableEntity, + }, + { + name: "fleets without premium", + body: map[string]any{ + "name": "Jane Doe", + "fleets": []map[string]any{{"id": 9999, "role": "observer"}}, + }, + wantStatus: http.StatusPaymentRequired, + }, + { + name: "api_endpoints without premium", + body: map[string]any{ + "name": "Jane Doe", + "global_role": "observer", + "api_endpoints": []map[string]any{ + {"method": "GET", "path": "/api/v1/fleet/hosts/:id"}, + }, + }, + wantStatus: http.StatusPaymentRequired, + }, + { + name: "both global_role and fleets without premium", + body: map[string]any{ + "name": "Jane Doe", + "global_role": "observer", + "fleets": []map[string]any{{"id": 9999, "role": "observer"}}, + }, + wantStatus: http.StatusPaymentRequired, + }, + { + name: "successful creation with global_role only", + body: map[string]any{ + "name": "Jane Doe", + "global_role": "observer", + }, + wantStatus: http.StatusOK, + verify: func(t *testing.T, resp createAPIOnlyUserResponse) { + require.NotEmpty(t, resp.Token, "token must be set") + require.NotZero(t, resp.User.ID, "user ID must be set") + require.Equal(t, "Jane Doe", resp.User.Name) + require.NotEmpty(t, resp.User.Email) + require.True(t, resp.User.APIOnly, "user must be api_only") + require.NotNil(t, resp.User.GlobalRole) + require.Equal(t, "observer", *resp.User.GlobalRole) + }, + }, + } - require.NotEmpty(t, createResp.Token, "token must be set") - require.NotZero(t, createResp.User.ID, "user ID must be set") - require.Equal(t, "Jane Doe", createResp.User.Name) - require.NotEmpty(t, createResp.User.Email) - require.True(t, createResp.User.APIOnly, "user must be api_only") - require.NotNil(t, createResp.User.GlobalRole) - require.Equal(t, "observer", *createResp.User.GlobalRole) + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + var resp createAPIOnlyUserResponse + s.DoJSON("POST", "/api/latest/fleet/users/api_only", tc.body, tc.wantStatus, &resp) + if tc.verify != nil { + tc.verify(t, resp) + } + }) + } } func (s *integrationTestSuite) TestModifyAPIOnlyUser() { diff --git a/server/service/integration_enterprise_test.go b/server/service/integration_enterprise_test.go index 68057593385..8b0331bd59e 100644 --- a/server/service/integration_enterprise_test.go +++ b/server/service/integration_enterprise_test.go @@ -28339,146 +28339,157 @@ func (s *integrationEnterpriseTestSuite) TestCreateAPIOnlyUserPremium() { team, err := s.ds.NewTeam(context.Background(), &fleet.Team{Name: t.Name() + "_team"}) require.NoError(t, err) - // --- Validation still enforced under premium --- - - // missing name → 422 (same as free tier) - s.Do("POST", "/api/latest/fleet/users/api_only", map[string]any{ - "fleets": []map[string]any{{"id": team.ID, "role": "observer"}}, - }, http.StatusUnprocessableEntity) - - // neither global_role nor fleets → 422 - s.Do("POST", "/api/latest/fleet/users/api_only", map[string]any{ - "name": "Premium User", - }, http.StatusUnprocessableEntity) - - // global_role AND fleets together → 422 (mutual exclusivity) - s.Do("POST", "/api/latest/fleet/users/api_only", map[string]any{ - "name": "Premium User", - "global_role": "observer", - "fleets": []map[string]any{{"id": team.ID, "role": "observer"}}, - }, http.StatusUnprocessableEntity) - - // invalid api_endpoint (not in catalog) → 422 - s.Do("POST", "/api/latest/fleet/users/api_only", map[string]any{ - "name": "Premium User", - "global_role": "observer", - "api_endpoints": []map[string]any{ - {"method": "GET", "path": "/api/v1/fleet/nonexistent/endpoint"}, - }, - }, http.StatusUnprocessableEntity) - - // wildcard mixed with other entries → 422 - s.Do("POST", "/api/latest/fleet/users/api_only", map[string]any{ - "name": "Premium User", - "global_role": "observer", - "api_endpoints": []map[string]any{ - {"method": "*", "path": "*"}, - {"method": "GET", "path": "/api/v1/fleet/config"}, - }, - }, http.StatusUnprocessableEntity) - - // --- Premium features work under premium --- - - // Create with wildcard api_endpoint → 200 - var createRespWildcard struct { - User struct { - ID uint `json:"id"` - Name string `json:"name"` - Email string `json:"email"` - APIOnly bool `json:"api_only"` - GlobalRole *string `json:"global_role"` - APIEndpoints []struct { - Method string `json:"method"` - Path string `json:"path"` - } `json:"api_endpoints"` - } `json:"user"` - Token string `json:"token"` - Err string `json:"error,omitempty"` + type apiEndpoint struct { + Method string `json:"method"` + Path string `json:"path"` } - s.DoJSON("POST", "/api/latest/fleet/users/api_only", map[string]any{ - "name": "Wildcard API User", - "global_role": "observer", - "api_endpoints": []map[string]any{ - {"method": "*", "path": "*"}, - }, - }, http.StatusOK, &createRespWildcard) - - require.NotEmpty(t, createRespWildcard.Token) - require.NotZero(t, createRespWildcard.User.ID) - require.Equal(t, "Wildcard API User", createRespWildcard.User.Name) - require.NotEmpty(t, createRespWildcard.User.Email) - require.True(t, createRespWildcard.User.APIOnly) - require.NotNil(t, createRespWildcard.User.GlobalRole) - require.Equal(t, "observer", *createRespWildcard.User.GlobalRole) - require.Len(t, createRespWildcard.User.APIEndpoints, 1) - require.Equal(t, "*", createRespWildcard.User.APIEndpoints[0].Method) - require.Equal(t, "*", createRespWildcard.User.APIEndpoints[0].Path) - - // Create with global_role and api_endpoints → 200 - var createRespGlobal struct { - User struct { - ID uint `json:"id"` - Name string `json:"name"` - Email string `json:"email"` - APIOnly bool `json:"api_only"` - GlobalRole *string `json:"global_role"` - APIEndpoints []struct { - Method string `json:"method"` - Path string `json:"path"` - } `json:"api_endpoints"` - } `json:"user"` - Token string `json:"token"` - Err string `json:"error,omitempty"` + type teamEntry struct { + ID uint `json:"id"` + Role string `json:"role"` } - s.DoJSON("POST", "/api/latest/fleet/users/api_only", map[string]any{ - "name": "Global API User", - "global_role": "observer", - "api_endpoints": []map[string]any{ - {"method": "GET", "path": "/api/v1/fleet/config"}, - {"method": "GET", "path": "/api/v1/fleet/version"}, - }, - }, http.StatusOK, &createRespGlobal) - - require.NotEmpty(t, createRespGlobal.Token) - require.NotZero(t, createRespGlobal.User.ID) - require.Equal(t, "Global API User", createRespGlobal.User.Name) - require.NotEmpty(t, createRespGlobal.User.Email) - require.True(t, createRespGlobal.User.APIOnly) - require.NotNil(t, createRespGlobal.User.GlobalRole) - require.Equal(t, "observer", *createRespGlobal.User.GlobalRole) - require.Len(t, createRespGlobal.User.APIEndpoints, 2) - - // Create with fleets (team assignment) → 200 - var createRespTeam struct { + type createAPIOnlyUserResponse struct { User struct { - ID uint `json:"id"` - Name string `json:"name"` - Email string `json:"email"` - APIOnly bool `json:"api_only"` - Teams []struct { - ID uint `json:"id"` - Role string `json:"role"` - } `json:"teams"` + ID uint `json:"id"` + Name string `json:"name"` + Email string `json:"email"` + APIOnly bool `json:"api_only"` + GlobalRole *string `json:"global_role"` + APIEndpoints []apiEndpoint `json:"api_endpoints"` + Teams []teamEntry `json:"teams"` } `json:"user"` Token string `json:"token"` Err string `json:"error,omitempty"` } - s.DoJSON("POST", "/api/latest/fleet/users/api_only", map[string]any{ - "name": "Team API User", - "fleets": []map[string]any{{"id": team.ID, "role": "observer"}}, - "api_endpoints": []map[string]any{ - {"method": "*", "path": "*"}, + + cases := []struct { + name string + body map[string]any + wantStatus int + verify func(t *testing.T, resp createAPIOnlyUserResponse) + }{ + // --- Validation still enforced under premium --- + { + name: "missing name", + body: map[string]any{ + "fleets": []map[string]any{{"id": team.ID, "role": "observer"}}, + }, + wantStatus: http.StatusUnprocessableEntity, + }, + { + name: "neither global_role nor fleets", + body: map[string]any{"name": "Premium User"}, + wantStatus: http.StatusUnprocessableEntity, + }, + { + name: "global_role and fleets together", + body: map[string]any{ + "name": "Premium User", + "global_role": "observer", + "fleets": []map[string]any{{"id": team.ID, "role": "observer"}}, + }, + wantStatus: http.StatusUnprocessableEntity, + }, + { + name: "invalid api_endpoint not in catalog", + body: map[string]any{ + "name": "Premium User", + "global_role": "observer", + "api_endpoints": []map[string]any{ + {"method": "GET", "path": "/api/v1/fleet/nonexistent/endpoint"}, + }, + }, + wantStatus: http.StatusUnprocessableEntity, + }, + { + name: "wildcard mixed with other entries", + body: map[string]any{ + "name": "Premium User", + "global_role": "observer", + "api_endpoints": []map[string]any{ + {"method": "*", "path": "*"}, + {"method": "GET", "path": "/api/v1/fleet/config"}, + }, + }, + wantStatus: http.StatusUnprocessableEntity, }, - }, http.StatusOK, &createRespTeam) + // --- Premium features work under premium --- + { + name: "wildcard api_endpoint", + body: map[string]any{ + "name": "Wildcard API User", + "global_role": "observer", + "api_endpoints": []map[string]any{ + {"method": "*", "path": "*"}, + }, + }, + wantStatus: http.StatusOK, + verify: func(t *testing.T, resp createAPIOnlyUserResponse) { + require.NotEmpty(t, resp.Token) + require.NotZero(t, resp.User.ID) + require.Equal(t, "Wildcard API User", resp.User.Name) + require.NotEmpty(t, resp.User.Email) + require.True(t, resp.User.APIOnly) + require.NotNil(t, resp.User.GlobalRole) + require.Equal(t, "observer", *resp.User.GlobalRole) + require.Len(t, resp.User.APIEndpoints, 1) + require.Equal(t, "*", resp.User.APIEndpoints[0].Method) + require.Equal(t, "*", resp.User.APIEndpoints[0].Path) + }, + }, + { + name: "global_role with specific api_endpoints", + body: map[string]any{ + "name": "Global API User", + "global_role": "observer", + "api_endpoints": []map[string]any{ + {"method": "GET", "path": "/api/v1/fleet/config"}, + {"method": "GET", "path": "/api/v1/fleet/version"}, + }, + }, + wantStatus: http.StatusOK, + verify: func(t *testing.T, resp createAPIOnlyUserResponse) { + require.NotEmpty(t, resp.Token) + require.NotZero(t, resp.User.ID) + require.Equal(t, "Global API User", resp.User.Name) + require.NotEmpty(t, resp.User.Email) + require.True(t, resp.User.APIOnly) + require.NotNil(t, resp.User.GlobalRole) + require.Equal(t, "observer", *resp.User.GlobalRole) + require.Len(t, resp.User.APIEndpoints, 2) + }, + }, + { + name: "fleet-scoped assignment with wildcard api_endpoint", + body: map[string]any{ + "name": "Team API User", + "fleets": []map[string]any{{"id": team.ID, "role": "observer"}}, + "api_endpoints": []map[string]any{ + {"method": "*", "path": "*"}, + }, + }, + wantStatus: http.StatusOK, + verify: func(t *testing.T, resp createAPIOnlyUserResponse) { + require.NotEmpty(t, resp.Token) + require.NotZero(t, resp.User.ID) + require.Equal(t, "Team API User", resp.User.Name) + require.NotEmpty(t, resp.User.Email) + require.True(t, resp.User.APIOnly) + require.Len(t, resp.User.Teams, 1) + require.Equal(t, team.ID, resp.User.Teams[0].ID) + require.Equal(t, "observer", resp.User.Teams[0].Role) + }, + }, + } - require.NotEmpty(t, createRespTeam.Token) - require.NotZero(t, createRespTeam.User.ID) - require.Equal(t, "Team API User", createRespTeam.User.Name) - require.NotEmpty(t, createRespTeam.User.Email) - require.True(t, createRespTeam.User.APIOnly) - require.Len(t, createRespTeam.User.Teams, 1) - require.Equal(t, team.ID, createRespTeam.User.Teams[0].ID) - require.Equal(t, "observer", createRespTeam.User.Teams[0].Role) + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + var resp createAPIOnlyUserResponse + s.DoJSON("POST", "/api/latest/fleet/users/api_only", tc.body, tc.wantStatus, &resp) + if tc.verify != nil { + tc.verify(t, resp) + } + }) + } } func (s *integrationEnterpriseTestSuite) TestModifyAPIOnlyUserPremium() { @@ -28531,78 +28542,140 @@ func (s *integrationEnterpriseTestSuite) TestModifyAPIOnlyUserPremium() { require.NotZero(t, createResp.User.ID) uid := createResp.User.ID patchURL := fmt.Sprintf("/api/latest/fleet/users/api_only/%d", uid) + nonAPIURL := fmt.Sprintf("/api/latest/fleet/users/api_only/%d", nonAPIUser.ID) - s.Do("PATCH", "/api/latest/fleet/users/api_only/999999", map[string]any{ - "name": "Ghost", - }, http.StatusNotFound) - - s.Do("PATCH", fmt.Sprintf("/api/latest/fleet/users/api_only/%d", nonAPIUser.ID), map[string]any{ - "name": "New Name", - }, http.StatusUnprocessableEntity) - - s.Do("PATCH", patchURL, map[string]any{ - "api_endpoints": []map[string]any{ - {"method": "GET", "path": "/api/v1/fleet/nonexistent/endpoint"}, + // Cases are order-dependent: success cases mutate the same user sequentially. + cases := []struct { + name string + url string + body map[string]any + wantStatus int + verify func(t *testing.T, resp patchResp) + }{ + // --- Validation errors --- + { + name: "nonexistent user", + url: "/api/latest/fleet/users/api_only/999999", + body: map[string]any{"name": "Ghost"}, + wantStatus: http.StatusNotFound, }, - }, http.StatusUnprocessableEntity) - - s.Do("PATCH", patchURL, map[string]any{ - "api_endpoints": []map[string]any{ - {"method": "*", "path": "*"}, - {"method": "GET", "path": "/api/v1/fleet/version"}, + { + name: "non-API-only user", + url: nonAPIURL, + body: map[string]any{"name": "New Name"}, + wantStatus: http.StatusUnprocessableEntity, }, - }, http.StatusUnprocessableEntity) - - s.Do("PATCH", patchURL, map[string]any{ - "api_endpoints": []map[string]any{}, - }, http.StatusUnprocessableEntity) - - var respName patchResp - s.DoJSON("PATCH", patchURL, map[string]any{ - "name": "Patched Name", - }, http.StatusOK, &respName) - require.Equal(t, "Patched Name", respName.User.Name) - require.True(t, respName.User.APIOnly) - - var respRole patchResp - s.DoJSON("PATCH", patchURL, map[string]any{ - "global_role": "admin", - }, http.StatusOK, &respRole) - require.NotNil(t, respRole.User.GlobalRole) - require.Equal(t, "admin", *respRole.User.GlobalRole) - - s.Do("PATCH", patchURL, map[string]any{ - "global_role": "observer", - "fleets": []map[string]any{{"id": team.ID, "role": "observer"}}, - }, http.StatusUnprocessableEntity) - - var respTeam patchResp - s.DoJSON("PATCH", patchURL, map[string]any{ - "fleets": []map[string]any{{"id": team.ID, "role": "observer"}}, - }, http.StatusOK, &respTeam) - require.Len(t, respTeam.User.Teams, 1) - require.Equal(t, team.ID, respTeam.User.Teams[0].ID) - require.Equal(t, "observer", respTeam.User.Teams[0].Role) - require.Nil(t, respTeam.User.GlobalRole, "global_role should be cleared when switching to fleet role") - - var respEndpoints patchResp - s.DoJSON("PATCH", patchURL, map[string]any{ - "api_endpoints": []map[string]any{ - {"method": "GET", "path": "/api/v1/fleet/config"}, - {"method": "GET", "path": "/api/v1/fleet/version"}, + { + name: "invalid api_endpoint not in catalog", + url: patchURL, + body: map[string]any{ + "api_endpoints": []map[string]any{ + {"method": "GET", "path": "/api/v1/fleet/nonexistent/endpoint"}, + }, + }, + wantStatus: http.StatusUnprocessableEntity, }, - }, http.StatusOK, &respEndpoints) - require.Len(t, respEndpoints.User.APIEndpoints, 2) - - var respWildcard patchResp - s.DoJSON("PATCH", patchURL, map[string]any{ - "api_endpoints": []map[string]any{ - {"method": "*", "path": "*"}, + { + name: "wildcard mixed with other entries", + url: patchURL, + body: map[string]any{ + "api_endpoints": []map[string]any{ + {"method": "*", "path": "*"}, + {"method": "GET", "path": "/api/v1/fleet/version"}, + }, + }, + wantStatus: http.StatusUnprocessableEntity, + }, + { + name: "empty api_endpoints", + url: patchURL, + body: map[string]any{"api_endpoints": []map[string]any{}}, + wantStatus: http.StatusUnprocessableEntity, + }, + // --- Successful mutations (order matters — each one builds on prior state) --- + { + name: "update name", + url: patchURL, + body: map[string]any{"name": "Patched Name"}, + wantStatus: http.StatusOK, + verify: func(t *testing.T, resp patchResp) { + require.Equal(t, "Patched Name", resp.User.Name) + require.True(t, resp.User.APIOnly) + }, + }, + { + name: "update global_role", + url: patchURL, + body: map[string]any{"global_role": "admin"}, + wantStatus: http.StatusOK, + verify: func(t *testing.T, resp patchResp) { + require.NotNil(t, resp.User.GlobalRole) + require.Equal(t, "admin", *resp.User.GlobalRole) + }, + }, + { + name: "global_role and fleets together", + url: patchURL, + body: map[string]any{ + "global_role": "observer", + "fleets": []map[string]any{{"id": team.ID, "role": "observer"}}, + }, + wantStatus: http.StatusUnprocessableEntity, }, - }, http.StatusOK, &respWildcard) - require.Len(t, respWildcard.User.APIEndpoints, 1) - require.Equal(t, "*", respWildcard.User.APIEndpoints[0].Method) - require.Equal(t, "*", respWildcard.User.APIEndpoints[0].Path) + { + name: "assign to fleet clears global_role", + url: patchURL, + body: map[string]any{ + "fleets": []map[string]any{{"id": team.ID, "role": "observer"}}, + }, + wantStatus: http.StatusOK, + verify: func(t *testing.T, resp patchResp) { + require.Len(t, resp.User.Teams, 1) + require.Equal(t, team.ID, resp.User.Teams[0].ID) + require.Equal(t, "observer", resp.User.Teams[0].Role) + require.Nil(t, resp.User.GlobalRole, "global_role should be cleared when switching to fleet role") + }, + }, + { + name: "update api_endpoints to specific endpoints", + url: patchURL, + body: map[string]any{ + "api_endpoints": []map[string]any{ + {"method": "GET", "path": "/api/v1/fleet/config"}, + {"method": "GET", "path": "/api/v1/fleet/version"}, + }, + }, + wantStatus: http.StatusOK, + verify: func(t *testing.T, resp patchResp) { + require.Len(t, resp.User.APIEndpoints, 2) + }, + }, + { + name: "update api_endpoints to wildcard", + url: patchURL, + body: map[string]any{ + "api_endpoints": []map[string]any{ + {"method": "*", "path": "*"}, + }, + }, + wantStatus: http.StatusOK, + verify: func(t *testing.T, resp patchResp) { + require.Len(t, resp.User.APIEndpoints, 1) + require.Equal(t, "*", resp.User.APIEndpoints[0].Method) + require.Equal(t, "*", resp.User.APIEndpoints[0].Path) + }, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + var resp patchResp + s.DoJSON("PATCH", tc.url, tc.body, tc.wantStatus, &resp) + if tc.verify != nil { + tc.verify(t, resp) + } + }) + } } func (s *integrationEnterpriseTestSuite) TestGetUserReturnsAPIEndpoints() { From 15f3e7e17b6a12787c6984aa4dbd5115b1f64d95 Mon Sep 17 00:00:00 2001 From: Juan Fernandez Date: Mon, 13 Apr 2026 07:29:27 -0400 Subject: [PATCH 07/18] Add limits to the number of api_endpoints we can send --- server/service/integration_enterprise_test.go | 27 ++++++++++++ server/service/users.go | 42 ++++++++++++------- 2 files changed, 55 insertions(+), 14 deletions(-) diff --git a/server/service/integration_enterprise_test.go b/server/service/integration_enterprise_test.go index 8b0331bd59e..fc994447d2e 100644 --- a/server/service/integration_enterprise_test.go +++ b/server/service/integration_enterprise_test.go @@ -28412,6 +28412,21 @@ func (s *integrationEnterpriseTestSuite) TestCreateAPIOnlyUserPremium() { }, wantStatus: http.StatusUnprocessableEntity, }, + { + name: "more than 100 api_endpoints", + body: func() map[string]any { + eps := make([]map[string]any, 101) + for i := range eps { + eps[i] = map[string]any{"method": "GET", "path": fmt.Sprintf("/api/v1/fleet/path/%d", i)} + } + return map[string]any{ + "name": "Premium User", + "global_role": "observer", + "api_endpoints": eps, + } + }(), + wantStatus: http.StatusUnprocessableEntity, + }, // --- Premium features work under premium --- { name: "wildcard api_endpoint", @@ -28586,6 +28601,18 @@ func (s *integrationEnterpriseTestSuite) TestModifyAPIOnlyUserPremium() { }, wantStatus: http.StatusUnprocessableEntity, }, + { + name: "more than 100 api_endpoints", + url: patchURL, + body: func() map[string]any { + eps := make([]map[string]any, 101) + for i := range eps { + eps[i] = map[string]any{"method": "GET", "path": fmt.Sprintf("/api/v1/fleet/path/%d", i)} + } + return map[string]any{"api_endpoints": eps} + }(), + wantStatus: http.StatusUnprocessableEntity, + }, { name: "empty api_endpoints", url: patchURL, diff --git a/server/service/users.go b/server/service/users.go index 114b9bce663..1959987b248 100644 --- a/server/service/users.go +++ b/server/service/users.go @@ -48,7 +48,7 @@ func createUserEndpoint(ctx context.Context, request interface{}, svc fleet.Serv return createUserResponse{ Err: fleet.NewInvalidArgumentError( "api_endpoints", - "API endpoints might only be specified when creating an API only user via the UI", + "This endpoint does not accept API endpoint values", ), }, nil } @@ -67,33 +67,49 @@ var errMailerRequiredForMFA = badRequest("Email must be set up to enable Fleet M func validateAPIEndpointRefs(ctx context.Context, refs *[]fleet.APIEndpointRef, requireNonEmpty bool) error { if refs == nil || len(*refs) == 0 { - if requireNonEmpty { - return ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("api_endpoints", "At least one API endpoint must be specified for API only users")) - } - if refs != nil { - // Explicitly provided empty slice is never valid. - return ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("api_endpoints", "At least one API endpoint must be specified for API only users")) + if requireNonEmpty || refs != nil { + return ctxerr.Wrap( + ctx, + fleet.NewInvalidArgumentError( + "api_endpoints", + "At least one API endpoint must be specified for API only users", + ), + ) } return nil } entries := *refs + if len(entries) > 100 { + return ctxerr.Wrap( + ctx, + fleet.NewInvalidArgumentError("api_endpoints", "at most 100 API endpoints may be specified"), + ) + } + // A single wildcard entry grants access to all endpoints — no catalog check needed. wildcardIdx := slices.IndexFunc(entries, func(e fleet.APIEndpointRef) bool { return e.Method == "*" && e.Path == "*" }) if wildcardIdx >= 0 { + // Specifying the wildcard entry plus something else doesn't make sense if len(entries) > 1 { - return ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("api_endpoints", "wildcard endpoint (method: *, path: *) must be the only entry")) + return ctxerr.Wrap( + ctx, + fleet.NewInvalidArgumentError( + "api_endpoints", + "wildcard endpoint (method: *, path: *) must be the only entry", + ), + ) } return nil } allEndpoints := apiendpoints.GetAPIEndpoints() - fpMap := make(map[string]fleet.APIEndpoint, len(allEndpoints)) + fpMap := make(map[string]struct{}, len(allEndpoints)) for _, ep := range allEndpoints { - fpMap[ep.Fingerprint()] = ep + fpMap[ep.Fingerprint()] = struct{}{} } seen := make(map[string]struct{}, len(entries)) hasDuplicates := false @@ -203,7 +219,7 @@ func (svc *Service) CreateUser(ctx context.Context, p fleet.UserPayload) (*fleet } if p.APIOnly != nil && *p.APIOnly { // API-Endpoints is a premium only feature, - // so we only require it if creating an API only + // so we only want to validate it if creating an API only // user under premium requireNonEmpty := license.IsPremium(ctx) if err := validateAPIEndpointRefs(ctx, p.APIEndpoints, requireNonEmpty); err != nil { @@ -257,8 +273,6 @@ func createAPIOnlyUserEndpoint(ctx context.Context, request any, svc fleet.Servi }, nil } - // This end-point is called only from the UI - // so we should have a logged-in user at this point. vc, ok := viewer.FromContext(ctx) if !ok { setAuthCheckedOnPreAuthErr(ctx) @@ -366,7 +380,7 @@ func createUserFromInviteEndpoint(ctx context.Context, request interface{}, svc return createUserResponse{ Err: fleet.NewInvalidArgumentError( "api_endpoints", - "API endpoints might only be specified when creating an API only user", + "This endpoint does not accept API endpoint values", ), }, nil } From 3799268e8b21177cdc379d2f0332fadeb2c26595 Mon Sep 17 00:00:00 2001 From: Juan Fernandez Date: Mon, 13 Apr 2026 07:36:59 -0400 Subject: [PATCH 08/18] Add missing checks --- server/service/integration_core_test.go | 27 +++++++++++++++++++++++++ server/service/users.go | 13 ++++++++++++ 2 files changed, 40 insertions(+) diff --git a/server/service/integration_core_test.go b/server/service/integration_core_test.go index 6eb5fa0087d..81ffaedc98f 100644 --- a/server/service/integration_core_test.go +++ b/server/service/integration_core_test.go @@ -333,6 +333,17 @@ func (s *integrationTestSuite) TestUserCreationWrongTeamErrors() { assertBodyContains(t, resp, `fleet with id 9999 does not exist`) } +func (s *integrationTestSuite) TestCreateUserAPIEndpointsRejected() { + var resp createUserResponse + s.DoJSON("POST", "/api/latest/fleet/users/admin", fleet.UserPayload{ + Name: ptr.String("user1"), + Email: ptr.String("apireject@example.com"), + Password: &test.GoodPassword, + GlobalRole: ptr.String(fleet.RoleObserver), + APIEndpoints: &[]fleet.APIEndpointRef{{Method: "GET", Path: "/api/v1/fleet/config"}}, + }, http.StatusUnprocessableEntity, &resp) +} + func (s *integrationTestSuite) TestCreateAPIOnlyUser() { t := s.T() @@ -2816,6 +2827,17 @@ func (s *integrationTestSuite) TestCreateUserFromInviteErrors() { }, http.StatusUnprocessableEntity, }, + { + "api_endpoints not accepted", + fleet.UserPayload{ + Name: ptr.String("Name"), + Password: &test.GoodPassword, + Email: ptr.String("a@b.c"), + InviteToken: ptr.String(invite.Token), + APIEndpoints: &[]fleet.APIEndpointRef{{Method: "GET", Path: "/api/v1/fleet/config"}}, + }, + http.StatusUnprocessableEntity, + }, } for _, c := range cases { @@ -10016,6 +10038,11 @@ func (s *integrationTestSuite) TestModifyUser() { resp.Body.Close() require.Equal(t, u.ID, loginResp.User.ID) + // as an admin, api_endpoints must be rejected on this endpoint + s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/users/%d", u.ID), fleet.UserPayload{ + APIEndpoints: &[]fleet.APIEndpointRef{{Method: "GET", Path: "/api/v1/fleet/config"}}, + }, http.StatusUnprocessableEntity, &modResp) + // as an admin, create a new user with SSO authentication enabled params = fleet.UserPayload{ Name: ptr.String("moduser1"), diff --git a/server/service/users.go b/server/service/users.go index 1959987b248..b6f436e9305 100644 --- a/server/service/users.go +++ b/server/service/users.go @@ -45,6 +45,7 @@ func createUserEndpoint(ctx context.Context, request interface{}, svc fleet.Serv req := request.(*createUserRequest) if req.APIEndpoints != nil { + setAuthCheckedOnPreAuthErr(ctx) return createUserResponse{ Err: fleet.NewInvalidArgumentError( "api_endpoints", @@ -377,6 +378,7 @@ func createUserFromInviteEndpoint(ctx context.Context, request interface{}, svc req := request.(*createUserRequest) if req.APIEndpoints != nil { + setAuthCheckedOnPreAuthErr(ctx) return createUserResponse{ Err: fleet.NewInvalidArgumentError( "api_endpoints", @@ -621,6 +623,17 @@ func (r modifyUserResponse) Error() error { return r.Err } func modifyUserEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) { req := request.(*modifyUserRequest) + + if req.APIEndpoints != nil { + setAuthCheckedOnPreAuthErr(ctx) + return modifyUserResponse{ + Err: fleet.NewInvalidArgumentError( + "api_endpoints", + "This endpoint does not accept API endpoint values", + ), + }, nil + } + user, err := svc.ModifyUser(ctx, req.ID, req.UserPayload) if err != nil { return modifyUserResponse{Err: err}, nil From f61916e280cb3aff3c6b2039df22a87b2797f7e9 Mon Sep 17 00:00:00 2001 From: Juan Fernandez Date: Mon, 13 Apr 2026 07:46:06 -0400 Subject: [PATCH 09/18] Make sure non-API users can be changed to be API-only users and viceversa --- server/service/integration_core_test.go | 45 +++++++++++++++++++++++++ server/service/users.go | 15 +++++++-- server/service/users_test.go | 35 +++++++++++++++++++ 3 files changed, 92 insertions(+), 3 deletions(-) diff --git a/server/service/integration_core_test.go b/server/service/integration_core_test.go index 81ffaedc98f..28b076400ce 100644 --- a/server/service/integration_core_test.go +++ b/server/service/integration_core_test.go @@ -344,6 +344,51 @@ func (s *integrationTestSuite) TestCreateUserAPIEndpointsRejected() { }, http.StatusUnprocessableEntity, &resp) } +func (s *integrationTestSuite) TestModifyUserAPIOnlyRejected() { + t := s.T() + + // Create a regular user to use as target. + var createResp createUserResponse + s.DoJSON("POST", "/api/latest/fleet/users/admin", fleet.UserPayload{ + Name: ptr.String("regular-api-protect"), + Email: ptr.String("regular-api-protect@example.com"), + Password: &test.GoodPassword, + GlobalRole: ptr.String(fleet.RoleObserver), + }, http.StatusOK, &createResp) + require.NotZero(t, createResp.User.ID) + regularID := createResp.User.ID + + // Create an API-only user to use as target. + var createAPIResp struct { + User struct { + ID uint `json:"id"` + } `json:"user"` + } + s.DoJSON("POST", "/api/latest/fleet/users/api_only", map[string]any{ + "name": "api-only-protect", + "global_role": "observer", + }, http.StatusOK, &createAPIResp) + require.NotZero(t, createAPIResp.User.ID) + apiOnlyID := createAPIResp.User.ID + + cases := []struct { + name string + userID uint + body fleet.UserPayload + }{ + {"regular user api_only false", regularID, fleet.UserPayload{APIOnly: new(false)}}, + {"regular user api_only true", regularID, fleet.UserPayload{APIOnly: new(true)}}, + {"api-only user api_only false", apiOnlyID, fleet.UserPayload{APIOnly: new(false)}}, + {"api-only user api_only true", apiOnlyID, fleet.UserPayload{APIOnly: new(true)}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + var modResp modifyUserResponse + s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/users/%d", tc.userID), tc.body, http.StatusUnprocessableEntity, &modResp) + }) + } +} + func (s *integrationTestSuite) TestCreateAPIOnlyUser() { t := s.T() diff --git a/server/service/users.go b/server/service/users.go index b6f436e9305..28015223982 100644 --- a/server/service/users.go +++ b/server/service/users.go @@ -634,6 +634,16 @@ func modifyUserEndpoint(ctx context.Context, request interface{}, svc fleet.Serv }, nil } + if req.APIOnly != nil { + setAuthCheckedOnPreAuthErr(ctx) + return modifyUserResponse{ + Err: fleet.NewInvalidArgumentError( + "api_only", + "This endpoint does not accept API only values", + ), + }, nil + } + user, err := svc.ModifyUser(ctx, req.ID, req.UserPayload) if err != nil { return modifyUserResponse{Err: err}, nil @@ -668,7 +678,6 @@ func (svc *Service) ModifyUser(ctx context.Context, userID uint, p fleet.UserPay if p.APIEndpoints != nil { return nil, fleet.ErrMissingLicense } - // Team-based API-only users are a premium feature. if p.APIOnly != nil && *p.APIOnly && len(teamRoles) > 0 { return nil, fleet.ErrMissingLicense } @@ -683,8 +692,8 @@ func (svc *Service) ModifyUser(ctx context.Context, userID uint, p fleet.UserPay return nil, ctxerr.Wrap(ctx, err, "verify user payload") } - if p.APIOnly != nil && *p.APIOnly && !user.APIOnly { - return nil, ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("id", "user is not an API-only user")) + if p.APIOnly != nil && *p.APIOnly != user.APIOnly { + return nil, ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("api_only", "cannot change api_only status of a user")) } if p.APIEndpoints != nil && !user.APIOnly { return nil, ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("api_endpoints", "API endpoints can only be specified for API only users")) diff --git a/server/service/users_test.go b/server/service/users_test.go index f2b8d085dea..9acf87b5bbb 100644 --- a/server/service/users_test.go +++ b/server/service/users_test.go @@ -1688,6 +1688,41 @@ func TestModifyUserLastAdminProtection(t *testing.T) { }) } +func TestModifyUserAPIOnlyStatusProtection(t *testing.T) { + setupModifyUserMocks := func(ds *mock.Store, targetUser *fleet.User) { + ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) { + return &fleet.AppConfig{}, nil + } + ds.UserByIDFunc = func(ctx context.Context, id uint) (*fleet.User, error) { + return targetUser, nil + } + } + + t.Run("cannot promote non-API user to API-only via api_only:true", func(t *testing.T) { + adminUser := newAdminTestUser(nil) + regularUser := newAdminTestUser(&adminTestUserOpts{id: 2, email: "regular@example.com", apiOnly: false}) + ds, svc, ctx := setupAdminTestContext(t, adminUser) + setupModifyUserMocks(ds, regularUser) + + _, err := svc.ModifyUser(ctx, regularUser.ID, fleet.UserPayload{APIOnly: new(true)}) + require.Error(t, err) + var argErr *fleet.InvalidArgumentError + require.ErrorAs(t, err, &argErr) + }) + + t.Run("cannot demote API-only user to non-API via api_only:false", func(t *testing.T) { + adminUser := newAdminTestUser(nil) + apiUser := newAdminTestUser(&adminTestUserOpts{id: 2, email: "api@example.com", apiOnly: true}) + ds, svc, ctx := setupAdminTestContext(t, adminUser) + setupModifyUserMocks(ds, apiUser) + + _, err := svc.ModifyUser(ctx, apiUser.ID, fleet.UserPayload{APIOnly: new(false)}) + require.Error(t, err) + var argErr *fleet.InvalidArgumentError + require.ErrorAs(t, err, &argErr) + }) +} + func TestPasswordChangeClearsTokensAndSessions(t *testing.T) { t.Run("ModifyUser with new password clears reset tokens and sessions", func(t *testing.T) { adminUser := newAdminTestUser(nil) From 5edd9cf6e072d44ba1045064a84d25b4dfca1303 Mon Sep 17 00:00:00 2001 From: Juan Fernandez Date: Mon, 13 Apr 2026 08:03:30 -0400 Subject: [PATCH 10/18] Added missing test --- server/service/integration_core_test.go | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/server/service/integration_core_test.go b/server/service/integration_core_test.go index 28b076400ce..e17e9cc5c2e 100644 --- a/server/service/integration_core_test.go +++ b/server/service/integration_core_test.go @@ -485,14 +485,17 @@ func (s *integrationTestSuite) TestModifyAPIOnlyUser() { User struct { ID uint `json:"id"` } `json:"user"` - Err string `json:"error,omitempty"` + Token string `json:"token"` + Err string `json:"error,omitempty"` } s.DoJSON("POST", "/api/latest/fleet/users/api_only", map[string]any{ "name": "API User", "global_role": "observer", }, http.StatusOK, &createResp) require.NotZero(t, createResp.User.ID) + require.NotEmpty(t, createResp.Token) apiUserID := createResp.User.ID + apiUserToken := createResp.Token s.DoRawNoAuth("PATCH", fmt.Sprintf("/api/latest/fleet/users/api_only/%d", apiUserID), []byte(`{}`), http.StatusUnauthorized) @@ -505,6 +508,14 @@ func (s *integrationTestSuite) TestModifyAPIOnlyUser() { "name": "New Name", }, http.StatusUnprocessableEntity) + // An API-only user cannot modify their own record via this endpoint. + s.token = apiUserToken + defer func() { s.token = s.getTestAdminToken() }() + s.Do("PATCH", fmt.Sprintf("/api/latest/fleet/users/api_only/%d", apiUserID), map[string]any{ + "name": "Self Update", + }, http.StatusUnprocessableEntity) + s.token = s.getTestAdminToken() + s.Do("PATCH", fmt.Sprintf("/api/latest/fleet/users/api_only/%d", apiUserID), map[string]any{ "api_endpoints": []map[string]any{ {"method": "GET", "path": "/api/v1/fleet/config"}, From bfd330f358c65a94ca07e1e006c668a062adddeb Mon Sep 17 00:00:00 2001 From: Juan Fernandez Date: Mon, 13 Apr 2026 09:42:36 -0400 Subject: [PATCH 11/18] PR feedback --- server/service/integration_core_test.go | 20 +++++++++++++++++++ server/service/integration_enterprise_test.go | 13 ++++++++++++ server/service/users.go | 20 +++++++++++++++++++ 3 files changed, 53 insertions(+) diff --git a/server/service/integration_core_test.go b/server/service/integration_core_test.go index e17e9cc5c2e..9cd0bc8a0ed 100644 --- a/server/service/integration_core_test.go +++ b/server/service/integration_core_test.go @@ -342,6 +342,15 @@ func (s *integrationTestSuite) TestCreateUserAPIEndpointsRejected() { GlobalRole: ptr.String(fleet.RoleObserver), APIEndpoints: &[]fleet.APIEndpointRef{{Method: "GET", Path: "/api/v1/fleet/config"}}, }, http.StatusUnprocessableEntity, &resp) + + // api_only must also be rejected on this endpoint. + s.DoJSON("POST", "/api/latest/fleet/users/admin", fleet.UserPayload{ + Name: ptr.String("user1"), + Email: ptr.String("apionlyreject@example.com"), + Password: &test.GoodPassword, + GlobalRole: ptr.String(fleet.RoleObserver), + APIOnly: new(true), + }, http.StatusUnprocessableEntity, &resp) } func (s *integrationTestSuite) TestModifyUserAPIOnlyRejected() { @@ -2894,6 +2903,17 @@ func (s *integrationTestSuite) TestCreateUserFromInviteErrors() { }, http.StatusUnprocessableEntity, }, + { + "api_only not accepted", + fleet.UserPayload{ + Name: ptr.String("Name"), + Password: &test.GoodPassword, + Email: ptr.String("a@b.c"), + InviteToken: ptr.String(invite.Token), + APIOnly: new(true), + }, + http.StatusUnprocessableEntity, + }, } for _, c := range cases { diff --git a/server/service/integration_enterprise_test.go b/server/service/integration_enterprise_test.go index fc994447d2e..8a4e38eff97 100644 --- a/server/service/integration_enterprise_test.go +++ b/server/service/integration_enterprise_test.go @@ -28774,6 +28774,19 @@ func (s *integrationEnterpriseTestSuite) TestGetUserReturnsAPIEndpoints() { require.False(t, getAdminResp.User.APIOnly) require.Empty(t, getAdminResp.User.APIEndpoints) + var getUsersResp listUsersResp + s.DoJSON("GET", "/api/latest/fleet/users", nil, http.StatusOK, &getUsersResp) + var foundRegular *userJSON + for i := range getUsersResp.Users { + if getUsersResp.Users[i].ID == regularUserResp.User.ID { + foundRegular = &getUsersResp.Users[i] + break + } + } + require.NotNil(t, foundRegular, "regular user should appear in list") + require.False(t, foundRegular.APIOnly) + require.Empty(t, foundRegular.APIEndpoints) + // Patch the API-only user to use a wildcard; GET should reflect the change. s.Do("PATCH", fmt.Sprintf("/api/latest/fleet/users/api_only/%d", apiUserID), map[string]any{ "api_endpoints": []map[string]any{ diff --git a/server/service/users.go b/server/service/users.go index 28015223982..e7b175fef1b 100644 --- a/server/service/users.go +++ b/server/service/users.go @@ -54,6 +54,16 @@ func createUserEndpoint(ctx context.Context, request interface{}, svc fleet.Serv }, nil } + if req.APIOnly != nil { + setAuthCheckedOnPreAuthErr(ctx) + return createUserResponse{ + Err: fleet.NewInvalidArgumentError( + "api_only", + "This endpoint does not accept API-only user creation", + ), + }, nil + } + user, sessionKey, err := svc.CreateUser(ctx, req.UserPayload) if err != nil { return createUserResponse{Err: err}, nil @@ -387,6 +397,16 @@ func createUserFromInviteEndpoint(ctx context.Context, request interface{}, svc }, nil } + if req.APIOnly != nil { + setAuthCheckedOnPreAuthErr(ctx) + return createUserResponse{ + Err: fleet.NewInvalidArgumentError( + "api_only", + "This endpoint does not accept API-only user creation", + ), + }, nil + } + user, err := svc.CreateUserFromInvite(ctx, req.UserPayload) if err != nil { return createUserResponse{Err: err}, nil From 2e05036148e6cdc0755215d4d3d36305a12f6fa1 Mon Sep 17 00:00:00 2001 From: Juan Fernandez Date: Mon, 13 Apr 2026 10:27:00 -0400 Subject: [PATCH 12/18] Preserve current API behavior --- server/service/integration_core_test.go | 35 ++++++++++++--------- server/service/users.go | 41 ++++++------------------- 2 files changed, 29 insertions(+), 47 deletions(-) diff --git a/server/service/integration_core_test.go b/server/service/integration_core_test.go index 9cd0bc8a0ed..815e1c29431 100644 --- a/server/service/integration_core_test.go +++ b/server/service/integration_core_test.go @@ -334,6 +334,9 @@ func (s *integrationTestSuite) TestUserCreationWrongTeamErrors() { } func (s *integrationTestSuite) TestCreateUserAPIEndpointsRejected() { + t := s.T() + + // api_endpoints cannot be specified directly on this endpoint. var resp createUserResponse s.DoJSON("POST", "/api/latest/fleet/users/admin", fleet.UserPayload{ Name: ptr.String("user1"), @@ -343,14 +346,27 @@ func (s *integrationTestSuite) TestCreateUserAPIEndpointsRejected() { APIEndpoints: &[]fleet.APIEndpointRef{{Method: "GET", Path: "/api/v1/fleet/config"}}, }, http.StatusUnprocessableEntity, &resp) - // api_only must also be rejected on this endpoint. + // api_only:true is accepted and automatically receives a wildcard api_endpoint. + var apiOnlyResp struct { + User struct { + APIOnly bool `json:"api_only"` + APIEndpoints []struct { + Method string `json:"method"` + Path string `json:"path"` + } `json:"api_endpoints"` + } `json:"user"` + } s.DoJSON("POST", "/api/latest/fleet/users/admin", fleet.UserPayload{ - Name: ptr.String("user1"), - Email: ptr.String("apionlyreject@example.com"), + Name: ptr.String("api-only-legacy"), + Email: ptr.String("api-only-legacy@example.com"), Password: &test.GoodPassword, GlobalRole: ptr.String(fleet.RoleObserver), APIOnly: new(true), - }, http.StatusUnprocessableEntity, &resp) + }, http.StatusOK, &apiOnlyResp) + require.True(t, apiOnlyResp.User.APIOnly) + require.Len(t, apiOnlyResp.User.APIEndpoints, 1) + require.Equal(t, "*", apiOnlyResp.User.APIEndpoints[0].Method) + require.Equal(t, "*", apiOnlyResp.User.APIEndpoints[0].Path) } func (s *integrationTestSuite) TestModifyUserAPIOnlyRejected() { @@ -2903,17 +2919,6 @@ func (s *integrationTestSuite) TestCreateUserFromInviteErrors() { }, http.StatusUnprocessableEntity, }, - { - "api_only not accepted", - fleet.UserPayload{ - Name: ptr.String("Name"), - Password: &test.GoodPassword, - Email: ptr.String("a@b.c"), - InviteToken: ptr.String(invite.Token), - APIOnly: new(true), - }, - http.StatusUnprocessableEntity, - }, } for _, c := range cases { diff --git a/server/service/users.go b/server/service/users.go index e7b175fef1b..e83e7b5fbf4 100644 --- a/server/service/users.go +++ b/server/service/users.go @@ -54,16 +54,6 @@ func createUserEndpoint(ctx context.Context, request interface{}, svc fleet.Serv }, nil } - if req.APIOnly != nil { - setAuthCheckedOnPreAuthErr(ctx) - return createUserResponse{ - Err: fleet.NewInvalidArgumentError( - "api_only", - "This endpoint does not accept API-only user creation", - ), - }, nil - } - user, sessionKey, err := svc.CreateUser(ctx, req.UserPayload) if err != nil { return createUserResponse{Err: err}, nil @@ -228,6 +218,13 @@ func (svc *Service) CreateUser(ctx context.Context, p fleet.UserPayload) (*fleet if p.APIEndpoints != nil && (p.APIOnly == nil || !*p.APIOnly) { return nil, nil, ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("api_endpoints", "API endpoints can only be specified for API only users")) } + if p.APIOnly != nil && *p.APIOnly && p.APIEndpoints == nil { + // Preserve backward-compatible behavior: creating an API-only user without + // explicit endpoints grants access to all endpoints via a wildcard. On + // premium, callers can provide granular endpoints; on free, the wildcard is + // always used since granular endpoints require premium. + p.APIEndpoints = &[]fleet.APIEndpointRef{{Method: "*", Path: "*"}} + } if p.APIOnly != nil && *p.APIOnly { // API-Endpoints is a premium only feature, // so we only want to validate it if creating an API only @@ -387,7 +384,7 @@ func (svc *Service) ModifyAPIOnlyUser(ctx context.Context, userID uint, p fleet. func createUserFromInviteEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) { req := request.(*createUserRequest) - if req.APIEndpoints != nil { + if req.APIOnly != nil || req.APIEndpoints != nil { setAuthCheckedOnPreAuthErr(ctx) return createUserResponse{ Err: fleet.NewInvalidArgumentError( @@ -397,16 +394,6 @@ func createUserFromInviteEndpoint(ctx context.Context, request interface{}, svc }, nil } - if req.APIOnly != nil { - setAuthCheckedOnPreAuthErr(ctx) - return createUserResponse{ - Err: fleet.NewInvalidArgumentError( - "api_only", - "This endpoint does not accept API-only user creation", - ), - }, nil - } - user, err := svc.CreateUserFromInvite(ctx, req.UserPayload) if err != nil { return createUserResponse{Err: err}, nil @@ -644,7 +631,7 @@ func (r modifyUserResponse) Error() error { return r.Err } func modifyUserEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) { req := request.(*modifyUserRequest) - if req.APIEndpoints != nil { + if req.APIOnly != nil || req.APIEndpoints != nil { setAuthCheckedOnPreAuthErr(ctx) return modifyUserResponse{ Err: fleet.NewInvalidArgumentError( @@ -654,16 +641,6 @@ func modifyUserEndpoint(ctx context.Context, request interface{}, svc fleet.Serv }, nil } - if req.APIOnly != nil { - setAuthCheckedOnPreAuthErr(ctx) - return modifyUserResponse{ - Err: fleet.NewInvalidArgumentError( - "api_only", - "This endpoint does not accept API only values", - ), - }, nil - } - user, err := svc.ModifyUser(ctx, req.ID, req.UserPayload) if err != nil { return modifyUserResponse{Err: err}, nil From 668728864dccd1a2c574a81882b325a2c7327c3e Mon Sep 17 00:00:00 2001 From: Juan Fernandez Date: Mon, 13 Apr 2026 10:35:33 -0400 Subject: [PATCH 13/18] Return more generic error --- server/service/users.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/service/users.go b/server/service/users.go index e83e7b5fbf4..32f2bea3d04 100644 --- a/server/service/users.go +++ b/server/service/users.go @@ -73,7 +73,7 @@ func validateAPIEndpointRefs(ctx context.Context, refs *[]fleet.APIEndpointRef, ctx, fleet.NewInvalidArgumentError( "api_endpoints", - "At least one API endpoint must be specified for API only users", + "at least one API endpoint must be specified for API only users", ), ) } @@ -85,7 +85,7 @@ func validateAPIEndpointRefs(ctx context.Context, refs *[]fleet.APIEndpointRef, if len(entries) > 100 { return ctxerr.Wrap( ctx, - fleet.NewInvalidArgumentError("api_endpoints", "at most 100 API endpoints may be specified"), + fleet.NewInvalidArgumentError("api_endpoints", "maximum number of API endpoints reached"), ) } From 02da70ef9c839f3480122b85a8c90fa1744810c4 Mon Sep 17 00:00:00 2001 From: Juan Fernandez Date: Mon, 13 Apr 2026 13:42:40 -0400 Subject: [PATCH 14/18] Remove wildcard rule --- server/datastore/mysql/users.go | 2 +- server/fleet/users.go | 20 ++++ server/service/client_users.go | 4 - server/service/integration_core_test.go | 15 +-- server/service/integration_enterprise_test.go | 57 ++++------- server/service/users.go | 94 ++++++++----------- 6 files changed, 79 insertions(+), 113 deletions(-) diff --git a/server/datastore/mysql/users.go b/server/datastore/mysql/users.go index ef0cab04444..6f785d3876f 100644 --- a/server/datastore/mysql/users.go +++ b/server/datastore/mysql/users.go @@ -315,7 +315,7 @@ func saveUserDB(ctx context.Context, tx sqlx.ExtContext, user *fleet.User) error return err } - if user.APIEndpoints != nil { + if user.APIOnly { if err := replaceUserAPIEndpoints(ctx, tx, user.ID, user.APIEndpoints); err != nil { return err } diff --git a/server/fleet/users.go b/server/fleet/users.go index 23f778e41a2..302eddc0875 100644 --- a/server/fleet/users.go +++ b/server/fleet/users.go @@ -30,6 +30,26 @@ type APIEndpointRef struct { Path string `json:"path"` } +// OptionalAPIEndpoints is a JSON-nullable field that distinguishes three states +// when decoding a PATCH request body: +// +// - Field absent → Present=false: no change to the user's current endpoints. +// - Field is null → Present=true, Value=nil: clear all entries (full access). +// - Field is an array → Present=true, Value=[...]: replace with specific entries. +type OptionalAPIEndpoints struct { + Present bool + Value []APIEndpointRef +} + +func (o *OptionalAPIEndpoints) UnmarshalJSON(data []byte) error { + o.Present = true + if string(data) == "null" { + o.Value = nil + return nil + } + return json.Unmarshal(data, &o.Value) +} + // User is the model struct that represents a Fleet user. type User struct { UpdateCreateTimestamps diff --git a/server/service/client_users.go b/server/service/client_users.go index 2a6947e4089..0af77fb350a 100644 --- a/server/service/client_users.go +++ b/server/service/client_users.go @@ -15,10 +15,6 @@ func (c *Client) CreateAPIOnlyUser(name string, globalRole *string, teams []flee GlobalRole: globalRole, Teams: &teams, } - if appCfg, err := c.GetAppConfig(); err == nil && appCfg.License != nil && appCfg.License.IsPremium() { - allEndpoints := []fleet.APIEndpointRef{{Path: "*", Method: "*"}} - payload.APIEndpoints = &allEndpoints - } var responseBody createUserResponse if err := c.authenticatedRequest(payload, verb, path, &responseBody); err != nil { return nil, err diff --git a/server/service/integration_core_test.go b/server/service/integration_core_test.go index 815e1c29431..772da4672e7 100644 --- a/server/service/integration_core_test.go +++ b/server/service/integration_core_test.go @@ -346,16 +346,7 @@ func (s *integrationTestSuite) TestCreateUserAPIEndpointsRejected() { APIEndpoints: &[]fleet.APIEndpointRef{{Method: "GET", Path: "/api/v1/fleet/config"}}, }, http.StatusUnprocessableEntity, &resp) - // api_only:true is accepted and automatically receives a wildcard api_endpoint. - var apiOnlyResp struct { - User struct { - APIOnly bool `json:"api_only"` - APIEndpoints []struct { - Method string `json:"method"` - Path string `json:"path"` - } `json:"api_endpoints"` - } `json:"user"` - } + var apiOnlyResp createUserResponse s.DoJSON("POST", "/api/latest/fleet/users/admin", fleet.UserPayload{ Name: ptr.String("api-only-legacy"), Email: ptr.String("api-only-legacy@example.com"), @@ -364,9 +355,7 @@ func (s *integrationTestSuite) TestCreateUserAPIEndpointsRejected() { APIOnly: new(true), }, http.StatusOK, &apiOnlyResp) require.True(t, apiOnlyResp.User.APIOnly) - require.Len(t, apiOnlyResp.User.APIEndpoints, 1) - require.Equal(t, "*", apiOnlyResp.User.APIEndpoints[0].Method) - require.Equal(t, "*", apiOnlyResp.User.APIEndpoints[0].Path) + require.Empty(t, apiOnlyResp.User.APIEndpoints) // nil/empty = full access } func (s *integrationTestSuite) TestModifyUserAPIOnlyRejected() { diff --git a/server/service/integration_enterprise_test.go b/server/service/integration_enterprise_test.go index 8a4e38eff97..a267f2d2c5d 100644 --- a/server/service/integration_enterprise_test.go +++ b/server/service/integration_enterprise_test.go @@ -28413,7 +28413,7 @@ func (s *integrationEnterpriseTestSuite) TestCreateAPIOnlyUserPremium() { wantStatus: http.StatusUnprocessableEntity, }, { - name: "more than 100 api_endpoints", + name: "allow only a limited number of api_endpoints", body: func() map[string]any { eps := make([]map[string]any, 101) for i := range eps { @@ -28427,28 +28427,18 @@ func (s *integrationEnterpriseTestSuite) TestCreateAPIOnlyUserPremium() { }(), wantStatus: http.StatusUnprocessableEntity, }, - // --- Premium features work under premium --- { - name: "wildcard api_endpoint", + name: "nil api_endpoints grants full access", body: map[string]any{ - "name": "Wildcard API User", + "name": "Full Access API User", "global_role": "observer", - "api_endpoints": []map[string]any{ - {"method": "*", "path": "*"}, - }, }, wantStatus: http.StatusOK, verify: func(t *testing.T, resp createAPIOnlyUserResponse) { require.NotEmpty(t, resp.Token) require.NotZero(t, resp.User.ID) - require.Equal(t, "Wildcard API User", resp.User.Name) - require.NotEmpty(t, resp.User.Email) require.True(t, resp.User.APIOnly) - require.NotNil(t, resp.User.GlobalRole) - require.Equal(t, "observer", *resp.User.GlobalRole) - require.Len(t, resp.User.APIEndpoints, 1) - require.Equal(t, "*", resp.User.APIEndpoints[0].Method) - require.Equal(t, "*", resp.User.APIEndpoints[0].Path) + require.Empty(t, resp.User.APIEndpoints) }, }, { @@ -28474,13 +28464,10 @@ func (s *integrationEnterpriseTestSuite) TestCreateAPIOnlyUserPremium() { }, }, { - name: "fleet-scoped assignment with wildcard api_endpoint", + name: "fleet-scoped assignment without api_endpoints grants full access", body: map[string]any{ "name": "Team API User", "fleets": []map[string]any{{"id": team.ID, "role": "observer"}}, - "api_endpoints": []map[string]any{ - {"method": "*", "path": "*"}, - }, }, wantStatus: http.StatusOK, verify: func(t *testing.T, resp createAPIOnlyUserResponse) { @@ -28492,6 +28479,7 @@ func (s *integrationEnterpriseTestSuite) TestCreateAPIOnlyUserPremium() { require.Len(t, resp.User.Teams, 1) require.Equal(t, team.ID, resp.User.Teams[0].ID) require.Equal(t, "observer", resp.User.Teams[0].Role) + require.Empty(t, resp.User.APIEndpoints) // nil = full access }, }, } @@ -28678,20 +28666,20 @@ func (s *integrationEnterpriseTestSuite) TestModifyAPIOnlyUserPremium() { }, }, { - name: "update api_endpoints to wildcard", - url: patchURL, - body: map[string]any{ - "api_endpoints": []map[string]any{ - {"method": "*", "path": "*"}, - }, - }, + name: "null api_endpoints resets to full access", + url: patchURL, + body: map[string]any{"api_endpoints": nil}, wantStatus: http.StatusOK, verify: func(t *testing.T, resp patchResp) { - require.Len(t, resp.User.APIEndpoints, 1) - require.Equal(t, "*", resp.User.APIEndpoints[0].Method) - require.Equal(t, "*", resp.User.APIEndpoints[0].Path) + require.Empty(t, resp.User.APIEndpoints) }, }, + { + name: "empty array is invalid", + url: patchURL, + body: map[string]any{"api_endpoints": []map[string]any{}}, + wantStatus: http.StatusUnprocessableEntity, + }, } for _, tc := range cases { @@ -28786,17 +28774,4 @@ func (s *integrationEnterpriseTestSuite) TestGetUserReturnsAPIEndpoints() { require.NotNil(t, foundRegular, "regular user should appear in list") require.False(t, foundRegular.APIOnly) require.Empty(t, foundRegular.APIEndpoints) - - // Patch the API-only user to use a wildcard; GET should reflect the change. - s.Do("PATCH", fmt.Sprintf("/api/latest/fleet/users/api_only/%d", apiUserID), map[string]any{ - "api_endpoints": []map[string]any{ - {"method": "*", "path": "*"}, - }, - }, http.StatusOK) - - var getAfterPatch getUserResp - s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/users/%d", apiUserID), nil, http.StatusOK, &getAfterPatch) - require.Len(t, getAfterPatch.User.APIEndpoints, 1) - require.Equal(t, "*", getAfterPatch.User.APIEndpoints[0].Method) - require.Equal(t, "*", getAfterPatch.User.APIEndpoints[0].Path) } diff --git a/server/service/users.go b/server/service/users.go index 32f2bea3d04..6d76eff16eb 100644 --- a/server/service/users.go +++ b/server/service/users.go @@ -8,7 +8,6 @@ import ( "fmt" "html/template" "net/http" - "slices" "time" "github.com/fleetdm/fleet/v4/server" @@ -66,19 +65,25 @@ func createUserEndpoint(ctx context.Context, request interface{}, svc fleet.Serv var errMailerRequiredForMFA = badRequest("Email must be set up to enable Fleet MFA") -func validateAPIEndpointRefs(ctx context.Context, refs *[]fleet.APIEndpointRef, requireNonEmpty bool) error { - if refs == nil || len(*refs) == 0 { - if requireNonEmpty || refs != nil { - return ctxerr.Wrap( - ctx, - fleet.NewInvalidArgumentError( - "api_endpoints", - "at least one API endpoint must be specified for API only users", - ), - ) - } +func validateAPIEndpointRefs(ctx context.Context, refs *[]fleet.APIEndpointRef) error { + if refs == nil { + // Absent (nil pointer): no change. + return nil + } + if *refs == nil { + // Null (non-nil pointer to nil slice): clear all entries — full access. return nil } + if len(*refs) == 0 { + // Explicit empty array: not valid; send null to grant full access. + return ctxerr.Wrap( + ctx, + fleet.NewInvalidArgumentError( + "api_endpoints", + "at least one API endpoint must be specified", + ), + ) + } entries := *refs @@ -89,24 +94,6 @@ func validateAPIEndpointRefs(ctx context.Context, refs *[]fleet.APIEndpointRef, ) } - // A single wildcard entry grants access to all endpoints — no catalog check needed. - wildcardIdx := slices.IndexFunc(entries, func(e fleet.APIEndpointRef) bool { - return e.Method == "*" && e.Path == "*" - }) - if wildcardIdx >= 0 { - // Specifying the wildcard entry plus something else doesn't make sense - if len(entries) > 1 { - return ctxerr.Wrap( - ctx, - fleet.NewInvalidArgumentError( - "api_endpoints", - "wildcard endpoint (method: *, path: *) must be the only entry", - ), - ) - } - return nil - } - allEndpoints := apiendpoints.GetAPIEndpoints() fpMap := make(map[string]struct{}, len(allEndpoints)) for _, ep := range allEndpoints { @@ -218,19 +205,8 @@ func (svc *Service) CreateUser(ctx context.Context, p fleet.UserPayload) (*fleet if p.APIEndpoints != nil && (p.APIOnly == nil || !*p.APIOnly) { return nil, nil, ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("api_endpoints", "API endpoints can only be specified for API only users")) } - if p.APIOnly != nil && *p.APIOnly && p.APIEndpoints == nil { - // Preserve backward-compatible behavior: creating an API-only user without - // explicit endpoints grants access to all endpoints via a wildcard. On - // premium, callers can provide granular endpoints; on free, the wildcard is - // always used since granular endpoints require premium. - p.APIEndpoints = &[]fleet.APIEndpointRef{{Method: "*", Path: "*"}} - } if p.APIOnly != nil && *p.APIOnly { - // API-Endpoints is a premium only feature, - // so we only want to validate it if creating an API only - // user under premium - requireNonEmpty := license.IsPremium(ctx) - if err := validateAPIEndpointRefs(ctx, p.APIEndpoints, requireNonEmpty); err != nil { + if err := validateAPIEndpointRefs(ctx, p.APIEndpoints); err != nil { return nil, nil, err } } @@ -321,11 +297,11 @@ func createAPIOnlyUserEndpoint(ctx context.Context, request any, svc fleet.Servi //////////////////////////////////////////////////////////////////////////////// type modifyAPIOnlyUserRequest struct { - ID uint `json:"-" url:"id"` - Name *string `json:"name,omitempty"` - GlobalRole *string `json:"global_role,omitempty"` - Teams *[]fleet.UserTeam `json:"teams,omitempty" renameto:"fleets"` - APIEndpoints *[]fleet.APIEndpointRef `json:"api_endpoints,omitempty"` + ID uint `json:"-" url:"id"` + Name *string `json:"name,omitempty"` + GlobalRole *string `json:"global_role,omitempty"` + Teams *[]fleet.UserTeam `json:"teams,omitempty" renameto:"fleets"` + APIEndpoints fleet.OptionalAPIEndpoints `json:"api_endpoints"` } type modifyAPIOnlyUserResponse struct { @@ -338,12 +314,22 @@ func (r modifyAPIOnlyUserResponse) Error() error { return r.Err } func modifyAPIOnlyUserEndpoint(ctx context.Context, request any, svc fleet.Service) (fleet.Errorer, error) { req := request.(*modifyAPIOnlyUserRequest) - user, err := svc.ModifyAPIOnlyUser(ctx, req.ID, fleet.UserPayload{ - Name: req.Name, - GlobalRole: req.GlobalRole, - Teams: req.Teams, - APIEndpoints: req.APIEndpoints, - }) + payload := fleet.UserPayload{ + Name: req.Name, + GlobalRole: req.GlobalRole, + Teams: req.Teams, + } + if req.APIEndpoints.Present { + if req.APIEndpoints.Value == nil { + // null → clear all entries; signal via non-nil pointer to nil slice. + var clear []fleet.APIEndpointRef + payload.APIEndpoints = &clear + } else { + payload.APIEndpoints = &req.APIEndpoints.Value + } + } + + user, err := svc.ModifyAPIOnlyUser(ctx, req.ID, payload) if err != nil { return modifyAPIOnlyUserResponse{Err: err}, nil } @@ -702,7 +688,7 @@ func (svc *Service) ModifyUser(ctx context.Context, userID uint, p fleet.UserPay return nil, err } } - if err := validateAPIEndpointRefs(ctx, p.APIEndpoints, false); err != nil { + if err := validateAPIEndpointRefs(ctx, p.APIEndpoints); err != nil { return nil, err } From 4bef2faa09c75392fadeb7c86dc624af1db83acf Mon Sep 17 00:00:00 2001 From: Juan Fernandez Date: Mon, 13 Apr 2026 14:33:21 -0400 Subject: [PATCH 15/18] Fixed linter issues and some PR feedback --- server/fleet/users.go | 6 +++--- server/service/users.go | 8 ++++---- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/server/fleet/users.go b/server/fleet/users.go index 302eddc0875..a74298e46f6 100644 --- a/server/fleet/users.go +++ b/server/fleet/users.go @@ -376,6 +376,9 @@ func (p UserPayload) User(keySize, cost int) (*User, error) { } if p.APIOnly != nil { user.APIOnly = *p.APIOnly + if p.APIEndpoints != nil { + user.APIEndpoints = *p.APIEndpoints + } } if p.Teams != nil { user.Teams = *p.Teams @@ -386,9 +389,6 @@ func (p UserPayload) User(keySize, cost int) (*User, error) { if p.InviteID != nil { user.InviteID = p.InviteID } - if p.APIEndpoints != nil { - user.APIEndpoints = *p.APIEndpoints - } return user, nil } diff --git a/server/service/users.go b/server/service/users.go index 6d76eff16eb..b317b6bd2bb 100644 --- a/server/service/users.go +++ b/server/service/users.go @@ -148,7 +148,7 @@ func (svc *Service) CreateUser(ctx context.Context, p fleet.UserPayload) (*fleet if fleet.PremiumRolesPresent(p.GlobalRole, teamRoles) { return nil, nil, fleet.ErrMissingLicense } - if p.APIEndpoints != nil { + if p.APIOnly != nil && *p.APIOnly && p.APIEndpoints != nil && *p.APIEndpoints != nil { return nil, nil, fleet.ErrMissingLicense } if p.APIOnly != nil && *p.APIOnly && len(teamRoles) > 0 { @@ -322,8 +322,8 @@ func modifyAPIOnlyUserEndpoint(ctx context.Context, request any, svc fleet.Servi if req.APIEndpoints.Present { if req.APIEndpoints.Value == nil { // null → clear all entries; signal via non-nil pointer to nil slice. - var clear []fleet.APIEndpointRef - payload.APIEndpoints = &clear + var emptyEndpoints []fleet.APIEndpointRef + payload.APIEndpoints = &emptyEndpoints } else { payload.APIEndpoints = &req.APIEndpoints.Value } @@ -658,7 +658,7 @@ func (svc *Service) ModifyUser(ctx context.Context, userID uint, p fleet.UserPay if fleet.PremiumRolesPresent(p.GlobalRole, teamRoles) { return nil, fleet.ErrMissingLicense } - if p.APIEndpoints != nil { + if user.APIOnly && p.APIEndpoints != nil && *p.APIEndpoints != nil { return nil, fleet.ErrMissingLicense } if p.APIOnly != nil && *p.APIOnly && len(teamRoles) > 0 { From a728b2d3b49182d1a1fe5b09298a4e4b37e35982 Mon Sep 17 00:00:00 2001 From: Juan Fernandez Date: Tue, 14 Apr 2026 08:10:08 -0400 Subject: [PATCH 16/18] Remove not needed migration --- ...9153714_AddApiEndpointPermissionsTables.go | 6 +-- ...000000_DropAuthorIdFromUserApiEndpoints.go | 43 ------------------- server/datastore/mysql/schema.sql | 6 +-- 3 files changed, 4 insertions(+), 51 deletions(-) delete mode 100644 server/datastore/mysql/migrations/tables/20260411000000_DropAuthorIdFromUserApiEndpoints.go diff --git a/server/datastore/mysql/migrations/tables/20260409153714_AddApiEndpointPermissionsTables.go b/server/datastore/mysql/migrations/tables/20260409153714_AddApiEndpointPermissionsTables.go index 817c4fbe5c0..0f5cfed5f84 100644 --- a/server/datastore/mysql/migrations/tables/20260409153714_AddApiEndpointPermissionsTables.go +++ b/server/datastore/mysql/migrations/tables/20260409153714_AddApiEndpointPermissionsTables.go @@ -13,16 +13,12 @@ func Up_20260409153714(tx *sql.Tx) error { _, err := tx.Exec(` CREATE TABLE user_api_endpoints ( user_id INT UNSIGNED NOT NULL, - path VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, method VARCHAR(10) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, - author_id INT UNSIGNED, PRIMARY KEY (user_id, path, method), - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, - FOREIGN KEY (author_id) REFERENCES users(id) ON DELETE SET NULL + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ) `) if err != nil { diff --git a/server/datastore/mysql/migrations/tables/20260411000000_DropAuthorIdFromUserApiEndpoints.go b/server/datastore/mysql/migrations/tables/20260411000000_DropAuthorIdFromUserApiEndpoints.go deleted file mode 100644 index 86509ca4c86..00000000000 --- a/server/datastore/mysql/migrations/tables/20260411000000_DropAuthorIdFromUserApiEndpoints.go +++ /dev/null @@ -1,43 +0,0 @@ -package tables - -import ( - "database/sql" - "fmt" - "strings" -) - -func init() { - MigrationClient.AddMigration(Up_20260411000000, Down_20260411000000) -} - -func Up_20260411000000(tx *sql.Tx) error { - // Find the foreign key constraint name for the author_id column specifically. - var constraintName string - err := tx.QueryRow(` - SELECT CONSTRAINT_NAME - FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE - WHERE TABLE_NAME = 'user_api_endpoints' - AND COLUMN_NAME = 'author_id' - AND CONSTRAINT_SCHEMA = DATABASE() - AND REFERENCED_TABLE_NAME = 'users' - `).Scan(&constraintName) - if err != nil && err != sql.ErrNoRows { - return fmt.Errorf("look up author_id foreign key: %w", err) - } - - if constraintName != "" { - escaped := strings.ReplaceAll(constraintName, "`", "``") - if _, err := tx.Exec(fmt.Sprintf( - "ALTER TABLE user_api_endpoints DROP FOREIGN KEY `%s`", escaped, - )); err != nil { - return fmt.Errorf("drop author_id foreign key: %w", err) - } - } - - _, err = tx.Exec(`ALTER TABLE user_api_endpoints DROP COLUMN author_id`) - return err -} - -func Down_20260411000000(tx *sql.Tx) error { - return nil -} diff --git a/server/datastore/mysql/schema.sql b/server/datastore/mysql/schema.sql index dc295e92067..b719732d423 100644 --- a/server/datastore/mysql/schema.sql +++ b/server/datastore/mysql/schema.sql @@ -1934,9 +1934,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=515 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +) /*!50100 TABLESPACE `innodb_system` */ ENGINE=InnoDB AUTO_INCREMENT=514 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,20250326161930,1,'2020-01-01 01:01:01'),(370,20250326161931,1,'2020-01-01 01:01:01'),(371,20250331042354,1,'2020-01-01 01:01:01'),(372,20250331154206,1,'2020-01-01 01:01:01'),(373,20250401155831,1,'2020-01-01 01:01:01'),(374,20250408133233,1,'2020-01-01 01:01:01'),(375,20250410104321,1,'2020-01-01 01:01:01'),(376,20250421085116,1,'2020-01-01 01:01:01'),(377,20250422095806,1,'2020-01-01 01:01:01'),(378,20250424153059,1,'2020-01-01 01:01:01'),(379,20250430103833,1,'2020-01-01 01:01:01'),(380,20250430112622,1,'2020-01-01 01:01:01'),(381,20250501162727,1,'2020-01-01 01:01:01'),(382,20250502154517,1,'2020-01-01 01:01:01'),(383,20250502222222,1,'2020-01-01 01:01:01'),(384,20250507170845,1,'2020-01-01 01:01:01'),(385,20250513162912,1,'2020-01-01 01:01:01'),(386,20250519161614,1,'2020-01-01 01:01:01'),(387,20250519170000,1,'2020-01-01 01:01:01'),(388,20250520153848,1,'2020-01-01 01:01:01'),(389,20250528115932,1,'2020-01-01 01:01:01'),(390,20250529102706,1,'2020-01-01 01:01:01'),(391,20250603105558,1,'2020-01-01 01:01:01'),(392,20250609102714,1,'2020-01-01 01:01:01'),(393,20250609112613,1,'2020-01-01 01:01:01'),(394,20250613103810,1,'2020-01-01 01:01:01'),(395,20250616193950,1,'2020-01-01 01:01:01'),(396,20250624140757,1,'2020-01-01 01:01:01'),(397,20250626130239,1,'2020-01-01 01:01:01'),(398,20250629131032,1,'2020-01-01 01:01:01'),(399,20250701155654,1,'2020-01-01 01:01:01'),(400,20250707095725,1,'2020-01-01 01:01:01'),(401,20250716152435,1,'2020-01-01 01:01:01'),(402,20250718091828,1,'2020-01-01 01:01:01'),(403,20250728122229,1,'2020-01-01 01:01:01'),(404,20250731122715,1,'2020-01-01 01:01:01'),(405,20250731151000,1,'2020-01-01 01:01:01'),(406,20250803000000,1,'2020-01-01 01:01:01'),(407,20250805083116,1,'2020-01-01 01:01:01'),(408,20250807140441,1,'2020-01-01 01:01:01'),(409,20250808000000,1,'2020-01-01 01:01:01'),(410,20250811155036,1,'2020-01-01 01:01:01'),(411,20250813205039,1,'2020-01-01 01:01:01'),(412,20250814123333,1,'2020-01-01 01:01:01'),(413,20250815130115,1,'2020-01-01 01:01:01'),(414,20250816115553,1,'2020-01-01 01:01:01'),(415,20250817154557,1,'2020-01-01 01:01:01'),(416,20250825113751,1,'2020-01-01 01:01:01'),(417,20250827113140,1,'2020-01-01 01:01:01'),(418,20250828120836,1,'2020-01-01 01:01:01'),(419,20250902112642,1,'2020-01-01 01:01:01'),(420,20250904091745,1,'2020-01-01 01:01:01'),(421,20250905090000,1,'2020-01-01 01:01:01'),(422,20250922083056,1,'2020-01-01 01:01:01'),(423,20250923120000,1,'2020-01-01 01:01:01'),(424,20250926123048,1,'2020-01-01 01:01:01'),(425,20251015103505,1,'2020-01-01 01:01:01'),(426,20251015103600,1,'2020-01-01 01:01:01'),(427,20251015103700,1,'2020-01-01 01:01:01'),(428,20251015103800,1,'2020-01-01 01:01:01'),(429,20251015103900,1,'2020-01-01 01:01:01'),(430,20251028140000,1,'2020-01-01 01:01:01'),(431,20251028140100,1,'2020-01-01 01:01:01'),(432,20251028140110,1,'2020-01-01 01:01:01'),(433,20251028140200,1,'2020-01-01 01:01:01'),(434,20251028140300,1,'2020-01-01 01:01:01'),(435,20251028140400,1,'2020-01-01 01:01:01'),(436,20251031154558,1,'2020-01-01 01:01:01'),(437,20251103160848,1,'2020-01-01 01:01:01'),(438,20251104112849,1,'2020-01-01 01:01:01'),(439,20251106000000,1,'2020-01-01 01:01:01'),(440,20251107164629,1,'2020-01-01 01:01:01'),(441,20251107170854,1,'2020-01-01 01:01:01'),(442,20251110172137,1,'2020-01-01 01:01:01'),(443,20251111153133,1,'2020-01-01 01:01:01'),(444,20251117020000,1,'2020-01-01 01:01:01'),(445,20251117020100,1,'2020-01-01 01:01:01'),(446,20251117020200,1,'2020-01-01 01:01:01'),(447,20251121100000,1,'2020-01-01 01:01:01'),(448,20251121124239,1,'2020-01-01 01:01:01'),(449,20251124090450,1,'2020-01-01 01:01:01'),(450,20251124135808,1,'2020-01-01 01:01:01'),(451,20251124140138,1,'2020-01-01 01:01:01'),(452,20251124162948,1,'2020-01-01 01:01:01'),(453,20251127113559,1,'2020-01-01 01:01:01'),(454,20251202162232,1,'2020-01-01 01:01:01'),(455,20251203170808,1,'2020-01-01 01:01:01'),(456,20251207050413,1,'2020-01-01 01:01:01'),(457,20251208215800,1,'2020-01-01 01:01:01'),(458,20251209221730,1,'2020-01-01 01:01:01'),(459,20251209221850,1,'2020-01-01 01:01:01'),(460,20251215163721,1,'2020-01-01 01:01:01'),(461,20251217000000,1,'2020-01-01 01:01:01'),(462,20251217120000,1,'2020-01-01 01:01:01'),(463,20251229000000,1,'2020-01-01 01:01:01'),(464,20251229000010,1,'2020-01-01 01:01:01'),(465,20251229000020,1,'2020-01-01 01:01:01'),(466,20260106000000,1,'2020-01-01 01:01:01'),(467,20260108200708,1,'2020-01-01 01:01:01'),(468,20260108214732,1,'2020-01-01 01:01:01'),(469,20260109231821,1,'2020-01-01 01:01:01'),(470,20260113012054,1,'2020-01-01 01:01:01'),(471,20260124200020,1,'2020-01-01 01:01:01'),(472,20260126150840,1,'2020-01-01 01:01:01'),(473,20260126210724,1,'2020-01-01 01:01:01'),(474,20260202151756,1,'2020-01-01 01:01:01'),(475,20260205184907,1,'2020-01-01 01:01:01'),(476,20260210151544,1,'2020-01-01 01:01:01'),(477,20260210155109,1,'2020-01-01 01:01:01'),(478,20260210181120,1,'2020-01-01 01:01:01'),(479,20260211200153,1,'2020-01-01 01:01:01'),(480,20260217141240,1,'2020-01-01 01:01:01'),(481,20260217200906,1,'2020-01-01 01:01:01'),(482,20260218175704,1,'2020-01-01 01:01:01'),(483,20260314120000,1,'2020-01-01 01:01:01'),(484,20260316120000,1,'2020-01-01 01:01:01'),(485,20260316120001,1,'2020-01-01 01:01:01'),(486,20260316120002,1,'2020-01-01 01:01:01'),(487,20260316120003,1,'2020-01-01 01:01:01'),(488,20260316120004,1,'2020-01-01 01:01:01'),(489,20260316120005,1,'2020-01-01 01:01:01'),(490,20260316120006,1,'2020-01-01 01:01:01'),(491,20260316120007,1,'2020-01-01 01:01:01'),(492,20260316120008,1,'2020-01-01 01:01:01'),(493,20260316120009,1,'2020-01-01 01:01:01'),(494,20260316120010,1,'2020-01-01 01:01:01'),(495,20260317120000,1,'2020-01-01 01:01:01'),(496,20260318184559,1,'2020-01-01 01:01:01'),(497,20260319120000,1,'2020-01-01 01:01:01'),(498,20260323144117,1,'2020-01-01 01:01:01'),(499,20260324161944,1,'2020-01-01 01:01:01'),(500,20260324223334,1,'2020-01-01 01:01:01'),(501,20260326131501,1,'2020-01-01 01:01:01'),(502,20260326210603,1,'2020-01-01 01:01:01'),(503,20260331000000,1,'2020-01-01 01:01:01'),(504,20260401153000,1,'2020-01-01 01:01:01'),(505,20260401153001,1,'2020-01-01 01:01:01'),(506,20260401153503,1,'2020-01-01 01:01:01'),(507,20260403120000,1,'2020-01-01 01:01:01'),(508,20260409153713,1,'2020-01-01 01:01:01'),(509,20260409153714,1,'2020-01-01 01:01:01'),(510,20260409153715,1,'2020-01-01 01:01:01'),(511,20260409153716,1,'2020-01-01 01:01:01'),(512,20260409153717,1,'2020-01-01 01:01:01'),(513,20260409183610,1,'2020-01-01 01:01:01'),(514,20260411000000,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,20250326161930,1,'2020-01-01 01:01:01'),(370,20250326161931,1,'2020-01-01 01:01:01'),(371,20250331042354,1,'2020-01-01 01:01:01'),(372,20250331154206,1,'2020-01-01 01:01:01'),(373,20250401155831,1,'2020-01-01 01:01:01'),(374,20250408133233,1,'2020-01-01 01:01:01'),(375,20250410104321,1,'2020-01-01 01:01:01'),(376,20250421085116,1,'2020-01-01 01:01:01'),(377,20250422095806,1,'2020-01-01 01:01:01'),(378,20250424153059,1,'2020-01-01 01:01:01'),(379,20250430103833,1,'2020-01-01 01:01:01'),(380,20250430112622,1,'2020-01-01 01:01:01'),(381,20250501162727,1,'2020-01-01 01:01:01'),(382,20250502154517,1,'2020-01-01 01:01:01'),(383,20250502222222,1,'2020-01-01 01:01:01'),(384,20250507170845,1,'2020-01-01 01:01:01'),(385,20250513162912,1,'2020-01-01 01:01:01'),(386,20250519161614,1,'2020-01-01 01:01:01'),(387,20250519170000,1,'2020-01-01 01:01:01'),(388,20250520153848,1,'2020-01-01 01:01:01'),(389,20250528115932,1,'2020-01-01 01:01:01'),(390,20250529102706,1,'2020-01-01 01:01:01'),(391,20250603105558,1,'2020-01-01 01:01:01'),(392,20250609102714,1,'2020-01-01 01:01:01'),(393,20250609112613,1,'2020-01-01 01:01:01'),(394,20250613103810,1,'2020-01-01 01:01:01'),(395,20250616193950,1,'2020-01-01 01:01:01'),(396,20250624140757,1,'2020-01-01 01:01:01'),(397,20250626130239,1,'2020-01-01 01:01:01'),(398,20250629131032,1,'2020-01-01 01:01:01'),(399,20250701155654,1,'2020-01-01 01:01:01'),(400,20250707095725,1,'2020-01-01 01:01:01'),(401,20250716152435,1,'2020-01-01 01:01:01'),(402,20250718091828,1,'2020-01-01 01:01:01'),(403,20250728122229,1,'2020-01-01 01:01:01'),(404,20250731122715,1,'2020-01-01 01:01:01'),(405,20250731151000,1,'2020-01-01 01:01:01'),(406,20250803000000,1,'2020-01-01 01:01:01'),(407,20250805083116,1,'2020-01-01 01:01:01'),(408,20250807140441,1,'2020-01-01 01:01:01'),(409,20250808000000,1,'2020-01-01 01:01:01'),(410,20250811155036,1,'2020-01-01 01:01:01'),(411,20250813205039,1,'2020-01-01 01:01:01'),(412,20250814123333,1,'2020-01-01 01:01:01'),(413,20250815130115,1,'2020-01-01 01:01:01'),(414,20250816115553,1,'2020-01-01 01:01:01'),(415,20250817154557,1,'2020-01-01 01:01:01'),(416,20250825113751,1,'2020-01-01 01:01:01'),(417,20250827113140,1,'2020-01-01 01:01:01'),(418,20250828120836,1,'2020-01-01 01:01:01'),(419,20250902112642,1,'2020-01-01 01:01:01'),(420,20250904091745,1,'2020-01-01 01:01:01'),(421,20250905090000,1,'2020-01-01 01:01:01'),(422,20250922083056,1,'2020-01-01 01:01:01'),(423,20250923120000,1,'2020-01-01 01:01:01'),(424,20250926123048,1,'2020-01-01 01:01:01'),(425,20251015103505,1,'2020-01-01 01:01:01'),(426,20251015103600,1,'2020-01-01 01:01:01'),(427,20251015103700,1,'2020-01-01 01:01:01'),(428,20251015103800,1,'2020-01-01 01:01:01'),(429,20251015103900,1,'2020-01-01 01:01:01'),(430,20251028140000,1,'2020-01-01 01:01:01'),(431,20251028140100,1,'2020-01-01 01:01:01'),(432,20251028140110,1,'2020-01-01 01:01:01'),(433,20251028140200,1,'2020-01-01 01:01:01'),(434,20251028140300,1,'2020-01-01 01:01:01'),(435,20251028140400,1,'2020-01-01 01:01:01'),(436,20251031154558,1,'2020-01-01 01:01:01'),(437,20251103160848,1,'2020-01-01 01:01:01'),(438,20251104112849,1,'2020-01-01 01:01:01'),(439,20251106000000,1,'2020-01-01 01:01:01'),(440,20251107164629,1,'2020-01-01 01:01:01'),(441,20251107170854,1,'2020-01-01 01:01:01'),(442,20251110172137,1,'2020-01-01 01:01:01'),(443,20251111153133,1,'2020-01-01 01:01:01'),(444,20251117020000,1,'2020-01-01 01:01:01'),(445,20251117020100,1,'2020-01-01 01:01:01'),(446,20251117020200,1,'2020-01-01 01:01:01'),(447,20251121100000,1,'2020-01-01 01:01:01'),(448,20251121124239,1,'2020-01-01 01:01:01'),(449,20251124090450,1,'2020-01-01 01:01:01'),(450,20251124135808,1,'2020-01-01 01:01:01'),(451,20251124140138,1,'2020-01-01 01:01:01'),(452,20251124162948,1,'2020-01-01 01:01:01'),(453,20251127113559,1,'2020-01-01 01:01:01'),(454,20251202162232,1,'2020-01-01 01:01:01'),(455,20251203170808,1,'2020-01-01 01:01:01'),(456,20251207050413,1,'2020-01-01 01:01:01'),(457,20251208215800,1,'2020-01-01 01:01:01'),(458,20251209221730,1,'2020-01-01 01:01:01'),(459,20251209221850,1,'2020-01-01 01:01:01'),(460,20251215163721,1,'2020-01-01 01:01:01'),(461,20251217000000,1,'2020-01-01 01:01:01'),(462,20251217120000,1,'2020-01-01 01:01:01'),(463,20251229000000,1,'2020-01-01 01:01:01'),(464,20251229000010,1,'2020-01-01 01:01:01'),(465,20251229000020,1,'2020-01-01 01:01:01'),(466,20260106000000,1,'2020-01-01 01:01:01'),(467,20260108200708,1,'2020-01-01 01:01:01'),(468,20260108214732,1,'2020-01-01 01:01:01'),(469,20260109231821,1,'2020-01-01 01:01:01'),(470,20260113012054,1,'2020-01-01 01:01:01'),(471,20260124200020,1,'2020-01-01 01:01:01'),(472,20260126150840,1,'2020-01-01 01:01:01'),(473,20260126210724,1,'2020-01-01 01:01:01'),(474,20260202151756,1,'2020-01-01 01:01:01'),(475,20260205184907,1,'2020-01-01 01:01:01'),(476,20260210151544,1,'2020-01-01 01:01:01'),(477,20260210155109,1,'2020-01-01 01:01:01'),(478,20260210181120,1,'2020-01-01 01:01:01'),(479,20260211200153,1,'2020-01-01 01:01:01'),(480,20260217141240,1,'2020-01-01 01:01:01'),(481,20260217200906,1,'2020-01-01 01:01:01'),(482,20260218175704,1,'2020-01-01 01:01:01'),(483,20260314120000,1,'2020-01-01 01:01:01'),(484,20260316120000,1,'2020-01-01 01:01:01'),(485,20260316120001,1,'2020-01-01 01:01:01'),(486,20260316120002,1,'2020-01-01 01:01:01'),(487,20260316120003,1,'2020-01-01 01:01:01'),(488,20260316120004,1,'2020-01-01 01:01:01'),(489,20260316120005,1,'2020-01-01 01:01:01'),(490,20260316120006,1,'2020-01-01 01:01:01'),(491,20260316120007,1,'2020-01-01 01:01:01'),(492,20260316120008,1,'2020-01-01 01:01:01'),(493,20260316120009,1,'2020-01-01 01:01:01'),(494,20260316120010,1,'2020-01-01 01:01:01'),(495,20260317120000,1,'2020-01-01 01:01:01'),(496,20260318184559,1,'2020-01-01 01:01:01'),(497,20260319120000,1,'2020-01-01 01:01:01'),(498,20260323144117,1,'2020-01-01 01:01:01'),(499,20260324161944,1,'2020-01-01 01:01:01'),(500,20260324223334,1,'2020-01-01 01:01:01'),(501,20260326131501,1,'2020-01-01 01:01:01'),(502,20260326210603,1,'2020-01-01 01:01:01'),(503,20260331000000,1,'2020-01-01 01:01:01'),(504,20260401153000,1,'2020-01-01 01:01:01'),(505,20260401153001,1,'2020-01-01 01:01:01'),(506,20260401153503,1,'2020-01-01 01:01:01'),(507,20260403120000,1,'2020-01-01 01:01:01'),(508,20260409153713,1,'2020-01-01 01:01:01'),(509,20260409153714,1,'2020-01-01 01:01:01'),(510,20260409153715,1,'2020-01-01 01:01:01'),(511,20260409153716,1,'2020-01-01 01:01:01'),(512,20260409153717,1,'2020-01-01 01:01:01'),(513,20260409183610,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` ( @@ -3002,7 +3002,7 @@ CREATE TABLE `user_api_endpoints` ( `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`user_id`,`path`,`method`), CONSTRAINT `user_api_endpoints_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE -) /*!50100 TABLESPACE `innodb_system` */ ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +) 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 */; From 10caf6881df0e69b953b5c9fca002c7829a9e259 Mon Sep 17 00:00:00 2001 From: Juan Fernandez Date: Tue, 14 Apr 2026 08:47:11 -0400 Subject: [PATCH 17/18] PR feedback --- cmd/fleetctl/fleetctl/user.go | 7 ++++++- server/datastore/mysql/users.go | 2 +- server/service/integration_core_test.go | 13 ++++++++++++ server/service/integration_enterprise_test.go | 7 ++++++- server/service/users.go | 21 ++++++++++++------- server/utils.go | 2 +- 6 files changed, 40 insertions(+), 12 deletions(-) diff --git a/cmd/fleetctl/fleetctl/user.go b/cmd/fleetctl/fleetctl/user.go index d649da1d28a..db7247615aa 100644 --- a/cmd/fleetctl/fleetctl/user.go +++ b/cmd/fleetctl/fleetctl/user.go @@ -134,7 +134,12 @@ func createUserCommand() *cli.Command { } fmt.Fprintln(c.App.Writer, "Successfully created new user!") - if appCfg, cfgErr := client.GetAppConfig(); cfgErr == nil && + appCfg, cfgErr := client.GetAppConfig() + if cfgErr != nil { + fmt.Fprintln(c.App.Writer, "Could not fetch app configuration") + } + + if cfgErr == nil && appCfg.License != nil && appCfg.License.IsPremium() { fmt.Fprintln(c.App.Writer, "To further customize endpoints this API-only user has access to, head to the Fleet UI.") } diff --git a/server/datastore/mysql/users.go b/server/datastore/mysql/users.go index 6f785d3876f..ec4a6066f83 100644 --- a/server/datastore/mysql/users.go +++ b/server/datastore/mysql/users.go @@ -83,7 +83,7 @@ func (ds *Datastore) NewUser(ctx context.Context, user *fleet.User) (*fleet.User return err } - if user.APIEndpoints != nil { + if user.APIOnly && user.APIEndpoints != nil { if err := replaceUserAPIEndpoints(ctx, tx, user.ID, user.APIEndpoints); err != nil { return err } diff --git a/server/service/integration_core_test.go b/server/service/integration_core_test.go index 772da4672e7..37bb13f7fe2 100644 --- a/server/service/integration_core_test.go +++ b/server/service/integration_core_test.go @@ -517,11 +517,24 @@ func (s *integrationTestSuite) TestModifyAPIOnlyUser() { "name": "New Name", }, http.StatusNotFound) + // Targeting a non-API-only user must be rejected. admin := s.users["admin1@example.com"] s.Do("PATCH", fmt.Sprintf("/api/latest/fleet/users/api_only/%d", admin.ID), map[string]any{ "name": "New Name", }, http.StatusUnprocessableEntity) + var createRegularResp createUserResponse + s.DoJSON("POST", "/api/latest/fleet/users/admin", fleet.UserPayload{ + Name: ptr.String("regular-modify-api-only"), + Email: ptr.String("regular-modify-api-only@example.com"), + Password: &test.GoodPassword, + GlobalRole: ptr.String(fleet.RoleObserver), + }, http.StatusOK, &createRegularResp) + require.NotZero(t, createRegularResp.User.ID) + s.Do("PATCH", fmt.Sprintf("/api/latest/fleet/users/api_only/%d", createRegularResp.User.ID), map[string]any{ + "name": "New Name", + }, http.StatusUnprocessableEntity) + // An API-only user cannot modify their own record via this endpoint. s.token = apiUserToken defer func() { s.token = s.getTestAdminToken() }() diff --git a/server/service/integration_enterprise_test.go b/server/service/integration_enterprise_test.go index a267f2d2c5d..1a80658fbe5 100644 --- a/server/service/integration_enterprise_test.go +++ b/server/service/integration_enterprise_test.go @@ -40,6 +40,7 @@ import ( "github.com/fleetdm/fleet/v4/pkg/optjson" "github.com/fleetdm/fleet/v4/pkg/scripts" "github.com/fleetdm/fleet/v4/server" + apiendpoints "github.com/fleetdm/fleet/v4/server/api_endpoints" "github.com/fleetdm/fleet/v4/server/config" "github.com/fleetdm/fleet/v4/server/contexts/installersize" "github.com/fleetdm/fleet/v4/server/contexts/license" @@ -28399,6 +28400,9 @@ func (s *integrationEnterpriseTestSuite) TestCreateAPIOnlyUserPremium() { }, }, wantStatus: http.StatusUnprocessableEntity, + verify: func(t *testing.T, resp createAPIOnlyUserResponse) { + require.Contains(t, resp.Err, "|GET|api/v1/fleet/nonexistent/endpoint|") + }, }, { name: "wildcard mixed with other entries", @@ -28415,7 +28419,8 @@ func (s *integrationEnterpriseTestSuite) TestCreateAPIOnlyUserPremium() { { name: "allow only a limited number of api_endpoints", body: func() map[string]any { - eps := make([]map[string]any, 101) + // One more than the catalog size to trigger the limit. + eps := make([]map[string]any, len(apiendpoints.GetAPIEndpoints())+1) for i := range eps { eps[i] = map[string]any{"method": "GET", "path": fmt.Sprintf("/api/v1/fleet/path/%d", i)} } diff --git a/server/service/users.go b/server/service/users.go index b317b6bd2bb..b1c598d3983 100644 --- a/server/service/users.go +++ b/server/service/users.go @@ -8,6 +8,7 @@ import ( "fmt" "html/template" "net/http" + "strings" "time" "github.com/fleetdm/fleet/v4/server" @@ -85,23 +86,23 @@ func validateAPIEndpointRefs(ctx context.Context, refs *[]fleet.APIEndpointRef) ) } + allEndpoints := apiendpoints.GetAPIEndpoints() entries := *refs - if len(entries) > 100 { + if len(entries) > len(allEndpoints) { return ctxerr.Wrap( ctx, fleet.NewInvalidArgumentError("api_endpoints", "maximum number of API endpoints reached"), ) } - allEndpoints := apiendpoints.GetAPIEndpoints() fpMap := make(map[string]struct{}, len(allEndpoints)) for _, ep := range allEndpoints { fpMap[ep.Fingerprint()] = struct{}{} } seen := make(map[string]struct{}, len(entries)) hasDuplicates := false - hasUnknown := false + var unknownFps []string for _, ref := range entries { fp := fleet.NewAPIEndpointFromTpl(ref.Method, ref.Path).Fingerprint() if _, dup := seen[fp]; dup { @@ -110,15 +111,15 @@ func validateAPIEndpointRefs(ctx context.Context, refs *[]fleet.APIEndpointRef) } seen[fp] = struct{}{} if _, ok := fpMap[fp]; !ok { - hasUnknown = true + unknownFps = append(unknownFps, fp) } } invalid := &fleet.InvalidArgumentError{} if hasDuplicates { invalid.Append("api_endpoints", "one or more api_endpoints entries are duplicated") } - if hasUnknown { - invalid.Append("api_endpoints", "one or more api_endpoints entries are invalid") + if len(unknownFps) > 0 { + invalid.Append("api_endpoints", fmt.Sprintf("one or more api_endpoints entries are invalid: %s", strings.Join(unknownFps, ", "))) } if invalid.HasErrors() { return ctxerr.Wrap(ctx, invalid, "validate api_endpoints") @@ -242,7 +243,7 @@ func (svc *Service) CreateUser(ctx context.Context, p fleet.UserPayload) (*fleet type createAPIOnlyUserRequest struct { Name *string `json:"name,omitempty"` GlobalRole *string `json:"global_role,omitempty"` - Teams *[]fleet.UserTeam `json:"teams,omitempty" renameto:"fleets"` + Fleets *[]fleet.UserTeam `json:"fleets,omitempty"` APIEndpoints *[]fleet.APIEndpointRef `json:"api_endpoints,omitempty"` } @@ -279,7 +280,7 @@ func createAPIOnlyUserEndpoint(ctx context.Context, request any, svc fleet.Servi APIOnly: new(true), AdminForcedPasswordReset: new(false), GlobalRole: req.GlobalRole, - Teams: req.Teams, + Teams: req.Fleets, APIEndpoints: req.APIEndpoints, }) if err != nil { @@ -346,6 +347,10 @@ func (svc *Service) ModifyAPIOnlyUser(ctx context.Context, userID uint, p fleet. return nil, err } + if !target.APIOnly { + return nil, ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("id", "target user is not an API-only user")) + } + vc, ok := viewer.FromContext(ctx) if !ok { return nil, ctxerr.New(ctx, "viewer not present") diff --git a/server/utils.go b/server/utils.go index c566ad17a7e..079c9f6e0e7 100644 --- a/server/utils.go +++ b/server/utils.go @@ -17,7 +17,7 @@ import ( ) // GenerateRandomEmail generates a random email using baseEmail as the base. -// For example: GenerateRandomEmail('juan@fleetdm.com') -> 'juan+somerandomtext@fleetdm.com' +// For example: GenerateRandomEmail('email@example.com') -> 'email+somerandomtext@example.com' func GenerateRandomEmail(baseEmail string) (string, error) { emailSuffix, err := GenerateRandomURLSafeText(10) if err != nil { From c2af1e238ad5bdf6dc6912abc17a3ef58113c56e Mon Sep 17 00:00:00 2001 From: Juan Fernandez Date: Wed, 15 Apr 2026 17:22:01 -0400 Subject: [PATCH 18/18] Fixed issues with serialization --- server/service/integration_enterprise_test.go | 8 +++++--- server/service/users.go | 19 +++++++++++++++++-- 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/server/service/integration_enterprise_test.go b/server/service/integration_enterprise_test.go index 1a80658fbe5..ea396f2239f 100644 --- a/server/service/integration_enterprise_test.go +++ b/server/service/integration_enterprise_test.go @@ -28358,8 +28358,9 @@ func (s *integrationEnterpriseTestSuite) TestCreateAPIOnlyUserPremium() { APIEndpoints []apiEndpoint `json:"api_endpoints"` Teams []teamEntry `json:"teams"` } `json:"user"` - Token string `json:"token"` - Err string `json:"error,omitempty"` + Token string `json:"token"` + Err string `json:"error,omitempty"` + Errors []map[string]string `json:"errors,omitempty"` } cases := []struct { @@ -28401,7 +28402,8 @@ func (s *integrationEnterpriseTestSuite) TestCreateAPIOnlyUserPremium() { }, wantStatus: http.StatusUnprocessableEntity, verify: func(t *testing.T, resp createAPIOnlyUserResponse) { - require.Contains(t, resp.Err, "|GET|api/v1/fleet/nonexistent/endpoint|") + require.Len(t, resp.Errors, 1) + require.Contains(t, resp.Errors[0]["reason"], "|GET|/api/v1/fleet/nonexistent/endpoint|") }, }, { diff --git a/server/service/users.go b/server/service/users.go index b1c598d3983..fa265a0c087 100644 --- a/server/service/users.go +++ b/server/service/users.go @@ -240,10 +240,15 @@ func (svc *Service) CreateUser(ctx context.Context, p fleet.UserPayload) (*fleet // Create API-Only user //////////////////////////////////////////////////////////////////////////////// +type fleetsPayload struct { + ID uint `json:"id" db:"id"` + Role string `json:"role" db:"role"` +} + type createAPIOnlyUserRequest struct { Name *string `json:"name,omitempty"` GlobalRole *string `json:"global_role,omitempty"` - Fleets *[]fleet.UserTeam `json:"fleets,omitempty"` + Fleets *[]fleetsPayload `json:"fleets,omitempty"` APIEndpoints *[]fleet.APIEndpointRef `json:"api_endpoints,omitempty"` } @@ -273,6 +278,16 @@ func createAPIOnlyUserEndpoint(ctx context.Context, request any, svc fleet.Servi }, nil } + var fleets []fleet.UserTeam + if req.Fleets != nil { + for _, t := range *req.Fleets { + val := fleet.UserTeam{} + val.ID = t.ID + val.Role = t.Role + fleets = append(fleets, val) + } + } + user, token, err := svc.CreateUser(ctx, fleet.UserPayload{ Name: req.Name, Email: &email, @@ -280,7 +295,7 @@ func createAPIOnlyUserEndpoint(ctx context.Context, request any, svc fleet.Servi APIOnly: new(true), AdminForcedPasswordReset: new(false), GlobalRole: req.GlobalRole, - Teams: req.Fleets, + Teams: &fleets, APIEndpoints: req.APIEndpoints, }) if err != nil {