Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions changes/42882-42880-42884-allow-creation-of-api-only-users
Original file line number Diff line number Diff line change
@@ -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.
Comment thread
juan-fdz-hawa marked this conversation as resolved.
40 changes: 36 additions & 4 deletions cmd/fleetctl/fleetctl/user.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)",
Comment thread
juan-fdz-hawa marked this conversation as resolved.
},
Comment thread
juan-fdz-hawa marked this conversation as resolved.
&cli.StringFlag{
Name: nameFlagName,
Expand All @@ -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)",
Comment thread
juan-fdz-hawa marked this conversation as resolved.
},
&cli.BoolFlag{
Name: ssoFlagName,
Expand Down Expand Up @@ -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
}
Comment thread
juan-fdz-hawa marked this conversation as resolved.

if sso && len(password) > 0 {
return errors.New("Password may not be provided for SSO users.")
}
Expand Down
33 changes: 18 additions & 15 deletions cmd/fleetctl/fleetctl/users_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{}
}
Expand All @@ -91,45 +87,52 @@ 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
}

stdout := RunAppForTest(t, append(
[]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)
Expand Down
4 changes: 2 additions & 2 deletions server/api_endpoints/api_endpoints.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ func Init(h http.Handler) error {
return nil
})

loadedApiEndpoints, err := loadGetAPIEndpoints()
loadedApiEndpoints, err := loadAPIEndpoints()
if err != nil {
return err
}
Expand All @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
5 changes: 1 addition & 4 deletions server/datastore/mysql/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -3000,11 +3000,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 */;
Expand Down
89 changes: 87 additions & 2 deletions server/datastore/mysql/users.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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
}
}
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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")
}
3 changes: 3 additions & 0 deletions server/fleet/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
37 changes: 37 additions & 0 deletions server/fleet/users.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Comment thread
lucasmrod marked this conversation as resolved.
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
Expand All @@ -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"`
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

type UserSettings struct {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading