Skip to content
Merged
Show file tree
Hide file tree
Changes from 15 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.
35 changes: 31 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,34 @@ 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!")
if appCfg, cfgErr := client.GetAppConfig(); cfgErr == nil &&
Comment thread
lucasmrod marked this conversation as resolved.
Outdated
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
Comment thread
lucasmrod marked this conversation as resolved.
Outdated
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package tables

import (
"database/sql"
"fmt"
"strings"
)

func init() {
MigrationClient.AddMigration(Up_20260411000000, Down_20260411000000)
}

func Up_20260411000000(tx *sql.Tx) error {
// Find the foreign key constraint name for the author_id column specifically.
var constraintName string
err := tx.QueryRow(`
SELECT CONSTRAINT_NAME
FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE
WHERE TABLE_NAME = 'user_api_endpoints'
AND COLUMN_NAME = 'author_id'
AND CONSTRAINT_SCHEMA = DATABASE()
AND REFERENCED_TABLE_NAME = 'users'
`).Scan(&constraintName)
if err != nil && err != sql.ErrNoRows {
return fmt.Errorf("look up author_id foreign key: %w", err)
}

if constraintName != "" {
escaped := strings.ReplaceAll(constraintName, "`", "``")
if _, err := tx.Exec(fmt.Sprintf(
"ALTER TABLE user_api_endpoints DROP FOREIGN KEY `%s`", escaped,
)); err != nil {
return fmt.Errorf("drop author_id foreign key: %w", err)
}
}

_, err = tx.Exec(`ALTER TABLE user_api_endpoints DROP COLUMN author_id`)
Comment thread
juan-fdz-hawa marked this conversation as resolved.
Outdated
return err
}

func Down_20260411000000(tx *sql.Tx) error {
return nil
}
11 changes: 4 additions & 7 deletions server/datastore/mysql/schema.sql

Large diffs are not rendered by default.

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.APIEndpoints != nil {
Comment thread
lucasmrod marked this conversation as resolved.
Outdated
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
Loading
Loading