Skip to content

Commit 5a4a9ea

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 5a4a9ea

16 files changed

Lines changed: 1046 additions & 27 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. Not required when using --api-only (required otherwise)",
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+
// API-only (non-SSO) users are created via a dedicated endpoint that
128+
// generates the email and password server-side.
129+
if apiOnly {
130+
sessionKey, err := client.CreateAPIOnlyUser(name, globalRole, teams)
131+
if err != nil {
132+
return fmt.Errorf("Failed to create user: %w", err)
133+
}
134+
if sessionKey != nil && *sessionKey != "" {
135+
if appCfg, cfgErr := client.GetAppConfig(); cfgErr == nil &&
136+
appCfg.License != nil && appCfg.License.IsPremium() {
137+
fmt.Fprintln(c.App.Writer, "To further customize endpoints this API-only user has access to, head to the Fleet UI")
138+
}
139+
// Prevents blocking if we are executing a test
140+
if terminal.IsTerminal(int(os.Stdin.Fd())) {
141+
fmt.Fprint(c.App.Writer, "\nWhen you're ready to view the API token, press any key (will not be shown again): ")
142+
if _, err := os.Stdin.Read(make([]byte, 1)); err != nil {
143+
return fmt.Errorf("failed to read input: %w", err)
144+
}
145+
fmt.Fprintln(c.App.Writer)
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: 6 additions & 9 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
}
@@ -100,7 +96,7 @@ func TestUserCreateForcePasswordReset(t *testing.T) {
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,
106102
},
@@ -120,6 +116,7 @@ func TestUserCreateForcePasswordReset(t *testing.T) {
120116
ds.NewUserFuncInvoked = false
121117
ds.NewUserFunc = func(ctx context.Context, user *fleet.User) (*fleet.User, error) {
122118
assert.Equal(t, tc.expectedAdminForcePasswordReset, user.AdminForcedPasswordReset)
119+
createdUsers[user.Email] = user
123120
return user, nil
124121
}
125122

server/datastore/mysql/users.go

Lines changed: 134 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
"time"
1111

1212
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
13+
"github.com/fleetdm/fleet/v4/server/contexts/viewer"
1314
"github.com/fleetdm/fleet/v4/server/fleet"
1415
common_mysql "github.com/fleetdm/fleet/v4/server/platform/mysql"
1516
"github.com/jmoiron/sqlx"
@@ -36,6 +37,12 @@ func (ds *Datastore) NewUser(ctx context.Context, user *fleet.User) (*fleet.User
3637
return nil, ctxerr.Wrap(ctx, err, "validate role")
3738
}
3839

40+
var authorID *uint
41+
if vc, ok := viewer.FromContext(ctx); ok {
42+
id := vc.UserID()
43+
authorID = &id
44+
}
45+
3946
err := ds.withTx(ctx, func(tx sqlx.ExtContext) error {
4047
sqlStatement := `
4148
INSERT INTO users (
@@ -82,6 +89,13 @@ func (ds *Datastore) NewUser(ctx context.Context, user *fleet.User) (*fleet.User
8289
if err := saveTeamsForUserDB(ctx, tx, user); err != nil {
8390
return err
8491
}
92+
93+
if user.APIEndpoints != nil {
94+
if err := replaceUserAPIEndpoints(ctx, tx, user.ID, authorID, user.APIEndpoints); err != nil {
95+
return err
96+
}
97+
}
98+
8599
return nil
86100
})
87101
if err != nil {
@@ -111,6 +125,10 @@ func (ds *Datastore) findUser(ctx context.Context, searchCol string, searchVal i
111125
return nil, ctxerr.Wrap(ctx, err, "load teams")
112126
}
113127

128+
if err := ds.loadAPIEndpointsForUsers(ctx, []*fleet.User{user}); err != nil {
129+
return nil, ctxerr.Wrap(ctx, err, "load api endpoints")
130+
}
131+
114132
// When SSO is enabled, we can ignore forced password resets
115133
// However, we want to leave the db untouched, to cover cases where SSO is toggled
116134
if user.SSOEnabled {
@@ -174,6 +192,10 @@ func (ds *Datastore) ListUsers(ctx context.Context, opt fleet.UserListOptions) (
174192
return nil, ctxerr.Wrap(ctx, err, "load teams")
175193
}
176194

195+
if err := ds.loadAPIEndpointsForUsers(ctx, users); err != nil {
196+
return nil, ctxerr.Wrap(ctx, err, "load api endpoints")
197+
}
198+
177199
return users, nil
178200
}
179201

@@ -230,15 +252,25 @@ func (ds *Datastore) deletedUserByID(ctx context.Context, id uint) (*fleet.User,
230252
}
231253

232254
func (ds *Datastore) SaveUser(ctx context.Context, user *fleet.User) error {
255+
var authorID *uint
256+
if vc, ok := viewer.FromContext(ctx); ok {
257+
id := vc.UserID()
258+
authorID = &id
259+
}
233260
return ds.withTx(ctx, func(tx sqlx.ExtContext) error {
234-
return saveUserDB(ctx, tx, user)
261+
return saveUserDB(ctx, tx, user, authorID)
235262
})
236263
}
237264

238265
func (ds *Datastore) SaveUsers(ctx context.Context, users []*fleet.User) error {
266+
var authorID *uint
267+
if vc, ok := viewer.FromContext(ctx); ok {
268+
id := vc.UserID()
269+
authorID = &id
270+
}
239271
return ds.withTx(ctx, func(tx sqlx.ExtContext) error {
240272
for _, user := range users {
241-
err := saveUserDB(ctx, tx, user)
273+
err := saveUserDB(ctx, tx, user, authorID)
242274
if err != nil {
243275
return err
244276
}
@@ -247,7 +279,7 @@ func (ds *Datastore) SaveUsers(ctx context.Context, users []*fleet.User) error {
247279
})
248280
}
249281

250-
func saveUserDB(ctx context.Context, tx sqlx.ExtContext, user *fleet.User) error {
282+
func saveUserDB(ctx context.Context, tx sqlx.ExtContext, user *fleet.User, authorID *uint) error {
251283
if err := fleet.ValidateRole(user.GlobalRole, user.Teams); err != nil {
252284
return ctxerr.Wrap(ctx, err, "validate role")
253285
}
@@ -301,6 +333,57 @@ func saveUserDB(ctx context.Context, tx sqlx.ExtContext, user *fleet.User) error
301333
return err
302334
}
303335

336+
if user.APIEndpoints != nil {
337+
if err := replaceUserAPIEndpoints(ctx, tx, user.ID, authorID, user.APIEndpoints); err != nil {
338+
return err
339+
}
340+
}
341+
342+
return nil
343+
}
344+
345+
// loadAPIEndpointsForUsers loads api_endpoints for any API-only users in the slice.
346+
// Non-API-only users are left untouched.
347+
func (ds *Datastore) loadAPIEndpointsForUsers(ctx context.Context, users []*fleet.User) error {
348+
var apiOnlyIDs []uint
349+
for _, u := range users {
350+
if u.APIOnly {
351+
apiOnlyIDs = append(apiOnlyIDs, u.ID)
352+
}
353+
}
354+
if len(apiOnlyIDs) == 0 {
355+
return nil
356+
}
357+
358+
query, args, err := sqlx.In(
359+
`SELECT user_id, method, path FROM user_api_endpoints WHERE user_id IN (?) ORDER BY method, path`,
360+
apiOnlyIDs,
361+
)
362+
if err != nil {
363+
return ctxerr.Wrap(ctx, err, "build load api endpoints query")
364+
}
365+
366+
var rows []struct {
367+
UserID uint `db:"user_id"`
368+
Method string `db:"method"`
369+
Path string `db:"path"`
370+
}
371+
if err := sqlx.SelectContext(ctx, ds.reader(ctx), &rows, query, args...); err != nil {
372+
return ctxerr.Wrap(ctx, err, "load api endpoints for users")
373+
}
374+
375+
byUserID := make(map[uint][]fleet.APIEndpointRef, len(apiOnlyIDs))
376+
for _, row := range rows {
377+
byUserID[row.UserID] = append(byUserID[row.UserID], fleet.APIEndpointRef{
378+
Method: row.Method,
379+
Path: row.Path,
380+
})
381+
}
382+
for _, u := range users {
383+
if u.APIOnly {
384+
u.APIEndpoints = byUserID[u.ID] // nil when no endpoints assigned
385+
}
386+
}
304387
return nil
305388
}
306389

@@ -442,6 +525,11 @@ func (ds *Datastore) DeleteUserIfNotLastAdmin(ctx context.Context, id uint) erro
442525
// It uses SELECT ... FOR UPDATE to prevent concurrent requests from bypassing
443526
// the check (TOCTOU race condition).
444527
func (ds *Datastore) SaveUserIfNotLastAdmin(ctx context.Context, user *fleet.User) error {
528+
var authorID *uint
529+
if vc, ok := viewer.FromContext(ctx); ok {
530+
id := vc.UserID()
531+
authorID = &id
532+
}
445533
return ds.withTx(ctx, func(tx sqlx.ExtContext) error {
446534
// Lock the admin rows to prevent concurrent modifications.
447535
var count int
@@ -453,7 +541,7 @@ func (ds *Datastore) SaveUserIfNotLastAdmin(ctx context.Context, user *fleet.Use
453541
return fleet.ErrLastGlobalAdmin
454542
}
455543

456-
return saveUserDB(ctx, tx, user)
544+
return saveUserDB(ctx, tx, user, authorID)
457545
})
458546
}
459547

@@ -507,3 +595,45 @@ func (ds *Datastore) UserSettings(ctx context.Context, userID uint) (*fleet.User
507595
}
508596
return settings, nil
509597
}
598+
599+
// replaceUserAPIEndpoints replaces all API endpoint permissions for the given user.
600+
// authorID is the user who made the change; pass nil when there is no viewer context
601+
// (e.g., system-level operations), in which case author_id is stored as NULL.
602+
func replaceUserAPIEndpoints(ctx context.Context, tx sqlx.ExtContext, userID uint, authorID *uint, endpoints []fleet.APIEndpointRef) error {
603+
if _, err := tx.ExecContext(ctx, `DELETE FROM user_api_endpoints WHERE user_id = ?`, userID); err != nil {
604+
return ctxerr.Wrap(ctx, err, "delete user api endpoints")
605+
}
606+
if len(endpoints) == 0 {
607+
return nil
608+
}
609+
placeholders := strings.Repeat("(?, ?, ?, ?),", len(endpoints))
610+
placeholders = placeholders[:len(placeholders)-1] // trim trailing comma
611+
args := make([]any, 0, len(endpoints)*4)
612+
for _, ep := range endpoints {
613+
args = append(args, userID, ep.Path, ep.Method, authorID)
614+
}
615+
_, err := tx.ExecContext(ctx,
616+
`INSERT INTO user_api_endpoints (user_id, path, method, author_id) VALUES `+placeholders,
617+
args...,
618+
)
619+
return ctxerr.Wrap(ctx, err, "insert user api endpoints")
620+
}
621+
622+
// ListUserAPIEndpoints returns all API endpoint permissions assigned to the given user.
623+
func (ds *Datastore) ListUserAPIEndpoints(ctx context.Context, userID uint) ([]fleet.APIEndpoint, error) {
624+
var rows []struct {
625+
Method string `db:"method"`
626+
Path string `db:"path"`
627+
}
628+
if err := sqlx.SelectContext(ctx, ds.reader(ctx), &rows,
629+
`SELECT method, path FROM user_api_endpoints WHERE user_id = ? ORDER BY method, path`,
630+
userID,
631+
); err != nil {
632+
return nil, ctxerr.Wrap(ctx, err, "list user api endpoints")
633+
}
634+
endpoints := make([]fleet.APIEndpoint, 0, len(rows))
635+
for _, row := range rows {
636+
endpoints = append(endpoints, fleet.NewAPIEndpointFromTpl(row.Method, row.Path))
637+
}
638+
return endpoints, nil
639+
}

server/fleet/datastore.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,9 @@ type Datastore interface {
9797

9898
UserSettings(ctx context.Context, userID uint) (*UserSettings, error)
9999

100+
// ListUserAPIEndpoints returns the API endpoint permissions assigned to the given user.
101+
ListUserAPIEndpoints(ctx context.Context, userID uint) ([]APIEndpoint, error)
102+
100103
///////////////////////////////////////////////////////////////////////////////
101104
// QueryStore
102105

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
}

0 commit comments

Comments
 (0)