Skip to content

Commit a7ff10b

Browse files
committed
Allow the creation of API-only users
- Added POST /users/api_only endpoint for creating API-only users. - Added PATCH /users/api_only/{id} for updating existing API-only users. - Updated `fleetctl user create --api-only` removing email/password field requirements.
1 parent 577fe75 commit a7ff10b

15 files changed

Lines changed: 1014 additions & 31 deletions
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
- Added POST /users/api_only endpoint for creating API-only users.
2+
- Added PATCH /users/api_only/{id} for updating existing API-only users.
3+
- Updated `fleetctl user create --api-only` removing email/password field requirements.

cmd/fleetctl/fleetctl/user.go

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -38,9 +38,8 @@ func createUserCommand() *cli.Command {
3838
If a password is required and not provided by flag, the command will prompt for password input through stdin.`,
3939
Flags: []cli.Flag{
4040
&cli.StringFlag{
41-
Name: emailFlagName,
42-
Usage: "Email for new user (required)",
43-
Required: true,
41+
Name: emailFlagName,
42+
Usage: "Email for new user. This can be omitted if using --api-only (otherwise required)",
4443
},
4544
&cli.StringFlag{
4645
Name: nameFlagName,
@@ -49,7 +48,7 @@ func createUserCommand() *cli.Command {
4948
},
5049
&cli.StringFlag{
5150
Name: passwordFlagName,
52-
Usage: "Password for new user",
51+
Usage: "Password for new user. This can be omitted if using --api-only (otherwise required)",
5352
},
5453
&cli.BoolFlag{
5554
Name: ssoFlagName,
@@ -125,6 +124,31 @@ func createUserCommand() *cli.Command {
125124
}
126125
}
127126

127+
if apiOnly {
128+
sessionKey, err := client.CreateAPIOnlyUser(name, globalRole, teams)
129+
if err != nil {
130+
return fmt.Errorf("Failed to create user: %w", err)
131+
}
132+
133+
fmt.Fprintln(c.App.Writer, "Successfully created new user!")
134+
if appCfg, cfgErr := client.GetAppConfig(); cfgErr == nil &&
135+
appCfg.License != nil && appCfg.License.IsPremium() {
136+
fmt.Fprintln(c.App.Writer, "To further customize endpoints this API-only user has access to, head to the Fleet UI.")
137+
}
138+
139+
if sessionKey != nil && *sessionKey != "" {
140+
// Prevents blocking if we are executing a test
141+
if terminal.IsTerminal(int(os.Stdin.Fd())) { //nolint:gosec // ignore G115
142+
fmt.Fprint(c.App.Writer, "\nWhen you're ready to view the API token, press any key (will not be shown again): ")
143+
if _, err := os.Stdin.Read(make([]byte, 1)); err != nil {
144+
return fmt.Errorf("failed to read input: %w", err)
145+
}
146+
}
147+
fmt.Fprintf(c.App.Writer, "The API token for your new user is: %s\n", *sessionKey)
148+
}
149+
return nil
150+
}
151+
128152
if sso && len(password) > 0 {
129153
return errors.New("Password may not be provided for SSO users.")
130154
}

cmd/fleetctl/fleetctl/users_test.go

Lines changed: 18 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -58,15 +58,11 @@ func TestUserCreateForcePasswordReset(t *testing.T) {
5858
ds.InviteByEmailFunc = func(ctx context.Context, email string) (*fleet.Invite, error) {
5959
return nil, &notFoundError{}
6060
}
61+
// createdUsers tracks users created during tests so Login can find them by email.
62+
createdUsers := map[string]*fleet.User{}
6163
ds.UserByEmailFunc = func(ctx context.Context, email string) (*fleet.User, error) {
62-
if email == "bar@example.com" {
63-
apiOnlyUser := &fleet.User{
64-
ID: 1,
65-
Email: email,
66-
}
67-
err := apiOnlyUser.SetPassword(pwd, 24, 10)
68-
require.NoError(t, err)
69-
return apiOnlyUser, nil
64+
if u, ok := createdUsers[email]; ok {
65+
return u, nil
7066
}
7167
return nil, &notFoundError{}
7268
}
@@ -91,45 +87,52 @@ func TestUserCreateForcePasswordReset(t *testing.T) {
9187
args []string
9288
expectedAdminForcePasswordReset bool
9389
displaysToken bool
90+
isAPIOnly bool
9491
}{
9592
{
9693
name: "sso",
9794
args: []string{"--email", "foo@example.com", "--name", "foo", "--sso"},
9895
expectedAdminForcePasswordReset: false,
99-
displaysToken: false,
10096
},
10197
{
10298
name: "api-only",
103-
args: []string{"--email", "bar@example.com", "--password", pwd, "--name", "bar", "--api-only"},
99+
args: []string{"--name", "bar", "--api-only"},
104100
expectedAdminForcePasswordReset: false,
105101
displaysToken: true,
102+
isAPIOnly: true,
106103
},
107104
{
105+
// --sso is ignored by the api-only endpoint, so a password-based user
106+
// is always created and a token is always returned.
108107
name: "api-only-sso",
109108
args: []string{"--email", "baz@example.com", "--name", "baz", "--api-only", "--sso"},
110109
expectedAdminForcePasswordReset: false,
111-
displaysToken: false,
110+
displaysToken: true,
111+
isAPIOnly: true,
112112
},
113113
{
114114
name: "non-sso-non-api-only",
115115
args: []string{"--email", "zoo@example.com", "--password", pwd, "--name", "zoo"},
116116
expectedAdminForcePasswordReset: true,
117-
displaysToken: false,
118117
},
119118
} {
120119
ds.NewUserFuncInvoked = false
121120
ds.NewUserFunc = func(ctx context.Context, user *fleet.User) (*fleet.User, error) {
122121
assert.Equal(t, tc.expectedAdminForcePasswordReset, user.AdminForcedPasswordReset)
122+
createdUsers[user.Email] = user
123123
return user, nil
124124
}
125125

126126
stdout := RunAppForTest(t, append(
127127
[]string{"user", "create"},
128128
tc.args...,
129129
))
130-
if tc.displaysToken {
131-
require.Equal(t, stdout, fmt.Sprintf("Success! The API token for your new user is: %s\n", apiOnlyUserSessionKey))
132-
} else {
130+
switch {
131+
case tc.displaysToken:
132+
require.Equal(t, fmt.Sprintf("Successfully created new user!\nThe API token for your new user is: %s\n", apiOnlyUserSessionKey), stdout)
133+
case tc.isAPIOnly:
134+
require.Equal(t, "Successfully created new user!\n", stdout)
135+
default:
133136
require.Empty(t, stdout)
134137
}
135138
require.True(t, ds.NewUserFuncInvoked)
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
package tables
2+
3+
import (
4+
"database/sql"
5+
"fmt"
6+
)
7+
8+
func init() {
9+
MigrationClient.AddMigration(Up_20260411000000, Down_20260411000000)
10+
}
11+
12+
func Up_20260411000000(tx *sql.Tx) error {
13+
// Find the foreign key constraint name for the author_id column specifically.
14+
var constraintName string
15+
err := tx.QueryRow(`
16+
SELECT CONSTRAINT_NAME
17+
FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE
18+
WHERE TABLE_NAME = 'user_api_endpoints'
19+
AND COLUMN_NAME = 'author_id'
20+
AND CONSTRAINT_SCHEMA = DATABASE()
21+
AND REFERENCED_TABLE_NAME = 'users'
22+
`).Scan(&constraintName)
23+
if err != nil && err != sql.ErrNoRows {
24+
return fmt.Errorf("look up author_id foreign key: %w", err)
25+
}
26+
27+
if constraintName != "" {
28+
if _, err := tx.Exec(fmt.Sprintf(
29+
`ALTER TABLE user_api_endpoints DROP FOREIGN KEY %s`, constraintName,
30+
)); err != nil {
31+
return fmt.Errorf("drop author_id foreign key: %w", err)
32+
}
33+
}
34+
35+
_, err = tx.Exec(`ALTER TABLE user_api_endpoints DROP COLUMN author_id`)
36+
return err
37+
}
38+
39+
func Down_20260411000000(tx *sql.Tx) error {
40+
return nil
41+
}

server/datastore/mysql/users.go

Lines changed: 88 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,13 @@ func (ds *Datastore) NewUser(ctx context.Context, user *fleet.User) (*fleet.User
8282
if err := saveTeamsForUserDB(ctx, tx, user); err != nil {
8383
return err
8484
}
85+
86+
if user.APIEndpoints != nil {
87+
if err := replaceUserAPIEndpoints(ctx, tx, user.ID, user.APIEndpoints); err != nil {
88+
return err
89+
}
90+
}
91+
8592
return nil
8693
})
8794
if err != nil {
@@ -111,6 +118,10 @@ func (ds *Datastore) findUser(ctx context.Context, searchCol string, searchVal i
111118
return nil, ctxerr.Wrap(ctx, err, "load teams")
112119
}
113120

121+
if err := ds.loadAPIEndpointsForUsers(ctx, []*fleet.User{user}); err != nil {
122+
return nil, ctxerr.Wrap(ctx, err, "load api endpoints")
123+
}
124+
114125
// When SSO is enabled, we can ignore forced password resets
115126
// However, we want to leave the db untouched, to cover cases where SSO is toggled
116127
if user.SSOEnabled {
@@ -174,6 +185,10 @@ func (ds *Datastore) ListUsers(ctx context.Context, opt fleet.UserListOptions) (
174185
return nil, ctxerr.Wrap(ctx, err, "load teams")
175186
}
176187

188+
if err := ds.loadAPIEndpointsForUsers(ctx, users); err != nil {
189+
return nil, ctxerr.Wrap(ctx, err, "load api endpoints")
190+
}
191+
177192
return users, nil
178193
}
179194

@@ -238,8 +253,7 @@ func (ds *Datastore) SaveUser(ctx context.Context, user *fleet.User) error {
238253
func (ds *Datastore) SaveUsers(ctx context.Context, users []*fleet.User) error {
239254
return ds.withTx(ctx, func(tx sqlx.ExtContext) error {
240255
for _, user := range users {
241-
err := saveUserDB(ctx, tx, user)
242-
if err != nil {
256+
if err := saveUserDB(ctx, tx, user); err != nil {
243257
return err
244258
}
245259
}
@@ -301,6 +315,57 @@ func saveUserDB(ctx context.Context, tx sqlx.ExtContext, user *fleet.User) error
301315
return err
302316
}
303317

318+
if user.APIEndpoints != nil {
319+
if err := replaceUserAPIEndpoints(ctx, tx, user.ID, user.APIEndpoints); err != nil {
320+
return err
321+
}
322+
}
323+
324+
return nil
325+
}
326+
327+
// loadAPIEndpointsForUsers loads api_endpoints for any API-only users in the slice.
328+
// Non-API-only users are left untouched.
329+
func (ds *Datastore) loadAPIEndpointsForUsers(ctx context.Context, users []*fleet.User) error {
330+
var apiOnlyIDs []uint
331+
for _, u := range users {
332+
if u.APIOnly {
333+
apiOnlyIDs = append(apiOnlyIDs, u.ID)
334+
}
335+
}
336+
if len(apiOnlyIDs) == 0 {
337+
return nil
338+
}
339+
340+
query, args, err := sqlx.In(
341+
`SELECT user_id, method, path FROM user_api_endpoints WHERE user_id IN (?) ORDER BY method, path`,
342+
apiOnlyIDs,
343+
)
344+
if err != nil {
345+
return ctxerr.Wrap(ctx, err, "build load api endpoints query")
346+
}
347+
348+
var rows []struct {
349+
UserID uint `db:"user_id"`
350+
Method string `db:"method"`
351+
Path string `db:"path"`
352+
}
353+
if err := sqlx.SelectContext(ctx, ds.reader(ctx), &rows, query, args...); err != nil {
354+
return ctxerr.Wrap(ctx, err, "load api endpoints for users")
355+
}
356+
357+
byUserID := make(map[uint][]fleet.APIEndpointRef, len(apiOnlyIDs))
358+
for _, row := range rows {
359+
byUserID[row.UserID] = append(byUserID[row.UserID], fleet.APIEndpointRef{
360+
Method: row.Method,
361+
Path: row.Path,
362+
})
363+
}
364+
for _, u := range users {
365+
if u.APIOnly {
366+
u.APIEndpoints = byUserID[u.ID] // nil when no endpoints assigned
367+
}
368+
}
304369
return nil
305370
}
306371

@@ -507,3 +572,24 @@ func (ds *Datastore) UserSettings(ctx context.Context, userID uint) (*fleet.User
507572
}
508573
return settings, nil
509574
}
575+
576+
// replaceUserAPIEndpoints replaces all API endpoint permissions for the given user.
577+
func replaceUserAPIEndpoints(ctx context.Context, tx sqlx.ExtContext, userID uint, endpoints []fleet.APIEndpointRef) error {
578+
if _, err := tx.ExecContext(ctx, `DELETE FROM user_api_endpoints WHERE user_id = ?`, userID); err != nil {
579+
return ctxerr.Wrap(ctx, err, "delete user api endpoints")
580+
}
581+
if len(endpoints) == 0 {
582+
return nil
583+
}
584+
placeholders := strings.Repeat("(?, ?, ?),", len(endpoints))
585+
placeholders = placeholders[:len(placeholders)-1] // trim trailing comma
586+
args := make([]any, 0, len(endpoints)*3)
587+
for _, ep := range endpoints {
588+
args = append(args, userID, ep.Path, ep.Method)
589+
}
590+
_, err := tx.ExecContext(ctx,
591+
`INSERT INTO user_api_endpoints (user_id, path, method) VALUES `+placeholders,
592+
args...,
593+
)
594+
return ctxerr.Wrap(ctx, err, "insert user api endpoints")
595+
}

server/fleet/service.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,10 @@ type Service interface {
195195
// ModifyUser updates a user's parameters given a UserPayload.
196196
ModifyUser(ctx context.Context, userID uint, p UserPayload) (user *User, err error)
197197

198+
// ModifyAPIOnlyUser patches the allowed properties of an existing API-only user.
199+
// Only name, global_role, teams and api_endpoints may be changed.
200+
ModifyAPIOnlyUser(ctx context.Context, userID uint, p UserPayload) (user *User, err error)
201+
198202
// DeleteUser permanently deletes the user identified by the provided ID.
199203
DeleteUser(ctx context.Context, id uint) (*User, error)
200204

server/fleet/users.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,12 @@ type UserSummary struct {
2424
APIOnly bool `db:"api_only"`
2525
}
2626

27+
// APIEndpointRef represents an endpoint an API-only user has access to.
28+
type APIEndpointRef struct {
29+
Method string `json:"method"`
30+
Path string `json:"path"`
31+
}
32+
2733
// User is the model struct that represents a Fleet user.
2834
type User struct {
2935
UpdateCreateTimestamps
@@ -50,6 +56,10 @@ type User struct {
5056

5157
Settings *UserSettings `json:"settings,omitempty"`
5258
Deleted bool `json:"-" db:"deleted"`
59+
60+
// APIEndpoints if this user is an API-only user, this returns
61+
// a list of all end-points the user has access to.
62+
APIEndpoints []APIEndpointRef `json:"api_endpoints,omitempty"`
5363
}
5464

5565
type UserSettings struct {
@@ -200,6 +210,10 @@ type UserPayload struct {
200210
NewPassword *string `json:"new_password,omitempty"`
201211
Settings *UserSettings `json:"settings,omitempty"`
202212
InviteID *uint `json:"-"`
213+
214+
// If this is an API-only user, then this can be used to specify which
215+
// API endpoints the user has access to
216+
APIEndpoints *[]APIEndpointRef `json:"api_endpoints,omitempty"`
203217
}
204218

205219
func (p *UserPayload) VerifyInviteCreate() error {
@@ -352,6 +366,9 @@ func (p UserPayload) User(keySize, cost int) (*User, error) {
352366
if p.InviteID != nil {
353367
user.InviteID = p.InviteID
354368
}
369+
if p.APIEndpoints != nil {
370+
user.APIEndpoints = *p.APIEndpoints
371+
}
355372

356373
return user, nil
357374
}

server/mock/service/service_mock.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,8 @@ type ResetPasswordFunc func(ctx context.Context, token string, password string)
8888

8989
type ModifyUserFunc func(ctx context.Context, userID uint, p fleet.UserPayload) (user *fleet.User, err error)
9090

91+
type ModifyAPIOnlyUserFunc func(ctx context.Context, userID uint, p fleet.UserPayload) (user *fleet.User, err error)
92+
9193
type DeleteUserFunc func(ctx context.Context, id uint) (*fleet.User, error)
9294

9395
type ChangeUserEmailFunc func(ctx context.Context, token string) (string, error)
@@ -1013,6 +1015,9 @@ type Service struct {
10131015
ModifyUserFunc ModifyUserFunc
10141016
ModifyUserFuncInvoked bool
10151017

1018+
ModifyAPIOnlyUserFunc ModifyAPIOnlyUserFunc
1019+
ModifyAPIOnlyUserFuncInvoked bool
1020+
10161021
DeleteUserFunc DeleteUserFunc
10171022
DeleteUserFuncInvoked bool
10181023

@@ -2487,6 +2492,13 @@ func (s *Service) ModifyUser(ctx context.Context, userID uint, p fleet.UserPaylo
24872492
return s.ModifyUserFunc(ctx, userID, p)
24882493
}
24892494

2495+
func (s *Service) ModifyAPIOnlyUser(ctx context.Context, userID uint, p fleet.UserPayload) (user *fleet.User, err error) {
2496+
s.mu.Lock()
2497+
s.ModifyAPIOnlyUserFuncInvoked = true
2498+
s.mu.Unlock()
2499+
return s.ModifyAPIOnlyUserFunc(ctx, userID, p)
2500+
}
2501+
24902502
func (s *Service) DeleteUser(ctx context.Context, id uint) (*fleet.User, error) {
24912503
s.mu.Lock()
24922504
s.DeleteUserFuncInvoked = true

0 commit comments

Comments
 (0)