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 0000000000..7de718ec59 --- /dev/null +++ b/changes/42882-42880-42884-allow-creation-of-api-only-users @@ -0,0 +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 8227284e69..db7247615a 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,39 @@ func createUserCommand() *cli.Command { } } + if apiOnly { + 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) + } + + fmt.Fprintln(c.App.Writer, "Successfully created new user!") + 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.") + } + + 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 21bedb74ac..7da5e99a60 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/api_endpoints/api_endpoints.go b/server/api_endpoints/api_endpoints.go index b9d7153f68..30c857c61a 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/datastore/mysql/migrations/tables/20260409153714_AddApiEndpointPermissionsTables.go b/server/datastore/mysql/migrations/tables/20260409153714_AddApiEndpointPermissionsTables.go index 817c4fbe5c..0f5cfed5f8 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/schema.sql b/server/datastore/mysql/schema.sql index 2e0c712e1e..162e964ba9 100644 --- a/server/datastore/mysql/schema.sql +++ b/server/datastore/mysql/schema.sql @@ -3002,11 +3002,8 @@ 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 + CONSTRAINT `user_api_endpoints_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; /*!40101 SET character_set_client = @saved_cs_client */; /*!40101 SET @saved_cs_client = @@character_set_client */; diff --git a/server/datastore/mysql/users.go b/server/datastore/mysql/users.go index db5dc39014..ec4a6066f8 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.APIOnly && 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,56 @@ func saveUserDB(ctx context.Context, tx sqlx.ExtContext, user *fleet.User) error return err } + if user.APIOnly { + 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. +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] + } + } return nil } @@ -507,3 +571,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] + 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 da3c3df32b..d512166528 100644 --- a/server/fleet/service.go +++ b/server/fleet/service.go @@ -195,6 +195,9 @@ 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 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. DeleteUser(ctx context.Context, id uint) (*User, error) diff --git a/server/fleet/users.go b/server/fleet/users.go index 6e738451ec..a74298e46f 100644 --- a/server/fleet/users.go +++ b/server/fleet/users.go @@ -24,6 +24,32 @@ 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"` +} + +// 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 @@ -50,6 +76,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 +230,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 { @@ -342,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 diff --git a/server/mock/service/service_mock.go b/server/mock/service/service_mock.go index 685cc9cec0..bc57ef8b6d 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 6845997515..0af77fb350 100644 --- a/server/service/client_users.go +++ b/server/service/client_users.go @@ -7,6 +7,21 @@ import ( "github.com/fleetdm/fleet/v4/server/fleet" ) +// 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{ + Name: &name, + GlobalRole: globalRole, + Teams: &teams, + } + 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 e48eb4fe2b..8e4b1cef4e 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 97406fe944..7b36da0be2 100644 --- a/server/service/integration_core_test.go +++ b/server/service/integration_core_test.go @@ -333,6 +333,223 @@ func (s *integrationTestSuite) TestUserCreationWrongTeamErrors() { assertBodyContains(t, resp, `fleet with id 9999 does not exist`) } +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"), + 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) + + 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"), + Password: &test.GoodPassword, + GlobalRole: ptr.String(fleet.RoleObserver), + APIOnly: new(true), + }, http.StatusOK, &apiOnlyResp) + require.True(t, apiOnlyResp.User.APIOnly) + require.Empty(t, apiOnlyResp.User.APIEndpoints) // nil/empty = full access +} + +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() + + type createAPIOnlyUserResponse 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"` + } + + 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) + }, + }, + } + + 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() { + t := s.T() + + var createResp struct { + User struct { + ID uint `json:"id"` + } `json:"user"` + 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) + + s.Do("PATCH", "/api/latest/fleet/users/api_only/999999", map[string]any{ + "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() }() + 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"}, + }, + }, http.StatusPaymentRequired) +} + func (s *integrationTestSuite) TestQueryCreationLogsActivity() { t := s.T() @@ -2693,6 +2910,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 { @@ -9893,6 +10121,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/integration_enterprise_test.go b/server/service/integration_enterprise_test.go index 2953e914d1..4840d0ff2d 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" @@ -28534,3 +28535,453 @@ 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) + + type apiEndpoint struct { + Method string `json:"method"` + Path string `json:"path"` + } + type teamEntry struct { + ID uint `json:"id"` + Role string `json:"role"` + } + type createAPIOnlyUserResponse 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 []apiEndpoint `json:"api_endpoints"` + Teams []teamEntry `json:"teams"` + } `json:"user"` + Token string `json:"token"` + Err string `json:"error,omitempty"` + Errors []map[string]string `json:"errors,omitempty"` + } + + 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, + verify: func(t *testing.T, resp createAPIOnlyUserResponse) { + require.Len(t, resp.Errors, 1) + require.Contains(t, resp.Errors[0]["reason"], "|GET|/api/v1/fleet/nonexistent/endpoint|") + }, + }, + { + 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, + }, + { + name: "allow only a limited number of api_endpoints", + body: func() map[string]any { + // 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)} + } + return map[string]any{ + "name": "Premium User", + "global_role": "observer", + "api_endpoints": eps, + } + }(), + wantStatus: http.StatusUnprocessableEntity, + }, + { + name: "nil api_endpoints grants full access", + body: map[string]any{ + "name": "Full Access API User", + "global_role": "observer", + }, + wantStatus: http.StatusOK, + verify: func(t *testing.T, resp createAPIOnlyUserResponse) { + require.NotEmpty(t, resp.Token) + require.NotZero(t, resp.User.ID) + require.True(t, resp.User.APIOnly) + require.Empty(t, resp.User.APIEndpoints) + }, + }, + { + 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 without api_endpoints grants full access", + body: map[string]any{ + "name": "Team API User", + "fleets": []map[string]any{{"id": team.ID, "role": "observer"}}, + }, + 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.Empty(t, resp.User.APIEndpoints) // nil = full access + }, + }, + } + + 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() { + t := s.T() + + team, err := s.ds.NewTeam(context.Background(), &fleet.Team{Name: t.Name() + "_team"}) + require.NoError(t, err) + + 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) + + 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"` + } + + 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) + nonAPIURL := fmt.Sprintf("/api/latest/fleet/users/api_only/%d", nonAPIUser.ID) + + // 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, + }, + { + name: "non-API-only user", + url: nonAPIURL, + body: map[string]any{"name": "New Name"}, + wantStatus: http.StatusUnprocessableEntity, + }, + { + 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, + }, + { + 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: "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, + 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, + }, + { + 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: "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.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 { + 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() { + 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. + // 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", regularUserResp.User.ID), nil, http.StatusOK, &getAdminResp) + 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) +} diff --git a/server/service/users.go b/server/service/users.go index 23226f4fe1..fa265a0c08 100644 --- a/server/service/users.go +++ b/server/service/users.go @@ -8,9 +8,11 @@ import ( "fmt" "html/template" "net/http" + "strings" "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,17 @@ 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 { + setAuthCheckedOnPreAuthErr(ctx) + return createUserResponse{ + Err: fleet.NewInvalidArgumentError( + "api_endpoints", + "This endpoint does not accept API endpoint values", + ), + }, nil + } + user, sessionKey, err := svc.CreateUser(ctx, req.UserPayload) if err != nil { return createUserResponse{Err: err}, nil @@ -53,6 +66,67 @@ 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) 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", + ), + ) + } + + allEndpoints := apiendpoints.GetAPIEndpoints() + entries := *refs + + if len(entries) > len(allEndpoints) { + return ctxerr.Wrap( + ctx, + fleet.NewInvalidArgumentError("api_endpoints", "maximum number of API endpoints reached"), + ) + } + + fpMap := make(map[string]struct{}, len(allEndpoints)) + for _, ep := range allEndpoints { + fpMap[ep.Fingerprint()] = struct{}{} + } + seen := make(map[string]struct{}, len(entries)) + hasDuplicates := false + var unknownFps []string + 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 { + unknownFps = append(unknownFps, fp) + } + } + invalid := &fleet.InvalidArgumentError{} + if hasDuplicates { + invalid.Append("api_endpoints", "one or more api_endpoints entries are duplicated") + } + 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") + } + 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 +140,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.APIOnly != nil && *p.APIOnly && p.APIEndpoints != nil && *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 +203,12 @@ 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.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 { + if err := validateAPIEndpointRefs(ctx, p.APIEndpoints); err != nil { + return nil, nil, err } } @@ -133,7 +222,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 +236,170 @@ func (svc *Service) CreateUser(ctx context.Context, p fleet.UserPayload) (*fleet return user, sessionKey, nil } +//////////////////////////////////////////////////////////////////////////////// +// 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 *[]fleetsPayload `json:"fleets,omitempty"` + 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 { + setAuthCheckedOnPreAuthErr(ctx) + return createUserResponse{ + Err: ctxerr.Wrap(ctx, err, "generate user password"), + }, nil + } + + 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 + } + + 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, + Password: &pwd, + APIOnly: new(true), + AdminForcedPasswordReset: new(false), + GlobalRole: req.GlobalRole, + Teams: &fleets, + 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.OptionalAPIEndpoints `json:"api_endpoints"` +} + +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) + + 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 emptyEndpoints []fleet.APIEndpointRef + payload.APIEndpoints = &emptyEndpoints + } else { + payload.APIEndpoints = &req.APIEndpoints.Value + } + } + + user, err := svc.ModifyAPIOnlyUser(ctx, req.ID, payload) + 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) { + target, err := svc.ds.UserByID(ctx, userID) + if err != nil { + setAuthCheckedOnPreAuthErr(ctx) + return nil, ctxerr.Wrap(ctx, err) + } + if err := svc.authz.Authorize(ctx, target, fleet.ActionWrite); err != nil { + 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") + } + 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.APIOnly != nil || req.APIEndpoints != nil { + setAuthCheckedOnPreAuthErr(ctx) + return createUserResponse{ + Err: fleet.NewInvalidArgumentError( + "api_endpoints", + "This endpoint does not accept API endpoint values", + ), + }, nil + } + user, err := svc.CreateUserFromInvite(ctx, req.UserPayload) if err != nil { return createUserResponse{Err: err}, nil @@ -389,6 +636,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.APIOnly != nil || 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 @@ -411,7 +669,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 +678,12 @@ func (svc *Service) ModifyUser(ctx context.Context, userID uint, p fleet.UserPay if fleet.PremiumRolesPresent(p.GlobalRole, teamRoles) { return nil, fleet.ErrMissingLicense } + if user.APIOnly && p.APIEndpoints != nil && *p.APIEndpoints != nil { + return nil, fleet.ErrMissingLicense + } + if p.APIOnly != nil && *p.APIOnly && len(teamRoles) > 0 { + return nil, fleet.ErrMissingLicense + } } vc, ok := viewer.FromContext(ctx) @@ -431,6 +695,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("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")) + } + 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); err != nil { + return nil, err + } + if p.MFAEnabled != nil { if *p.MFAEnabled && !user.MFAEnabled { lic, _ := license.FromContext(ctx) @@ -530,6 +811,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/service/users_test.go b/server/service/users_test.go index f2b8d085de..9acf87b5bb 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) diff --git a/server/utils.go b/server/utils.go index 3137145cf2..079c9f6e0e 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('email@example.com') -> 'email+somerandomtext@example.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 ed186a7556..d6de4a140c 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) + }) + } +}