Allow the creation of API-only users (#43440)

**Related issues:** 
- Resolves #42882 
- Resolves #42880 
- Resolves #42884 

# Changes

- 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.
This commit is contained in:
Juan Fernandez 2026-04-16 12:11:39 -03:00 committed by GitHub
parent 19a1a1044e
commit f791f4b309
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 1305 additions and 42 deletions

View file

@ -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.

View file

@ -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.")
}

View file

@ -58,15 +58,11 @@ func TestUserCreateForcePasswordReset(t *testing.T) {
ds.InviteByEmailFunc = func(ctx context.Context, email string) (*fleet.Invite, error) {
return nil, &notFoundError{}
}
// 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, &notFoundError{}
}
@ -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)

View file

@ -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 {

View file

@ -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 {

View file

@ -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 */;

View file

@ -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")
}

View file

@ -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)

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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{})

View file

@ -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"),

View file

@ -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, &regularUserResp)
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)
}

View file

@ -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

View file

@ -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)

View file

@ -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) {

View file

@ -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)
})
}
}