mirror of
https://github.com/fleetdm/fleet
synced 2026-04-21 13:37:30 +00:00
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:
parent
19a1a1044e
commit
f791f4b309
18 changed files with 1305 additions and 42 deletions
|
|
@ -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.
|
||||
|
|
@ -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.")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 */;
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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{})
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue