Issue 1359 fleetctl team transfer (#1413)

* wip

* Add delete user command and translator

* Add host transfer command

* Add changes file

* Undo bad refactor

* Fix copypaste error

* Implement with interfaces instead of assertions

* Ad documentation and simplify implementation further

* Update docs/1-Using-Fleet/3-REST-API.md

Co-authored-by: Zach Wasserman <zach@fleetdm.com>

Co-authored-by: Zach Wasserman <zach@fleetdm.com>
This commit is contained in:
Tomas Touceda 2021-07-21 14:03:10 -03:00 committed by GitHub
parent 567f43d4a3
commit 484c6153e3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 703 additions and 3 deletions

View File

@ -0,0 +1 @@
* Add host transfer capabilities to fleetctl. Fixes issue 1359.

View File

@ -0,0 +1 @@
* Add user delete capabilities to fleetctl. Fixes issue 1360

View File

@ -60,6 +60,7 @@ func createApp(reader io.Reader, writer io.Writer, exitErrHandler cli.ExitErrHan
debugCommand(),
previewCommand(),
eefleetctl.UpdatesCommand(),
hostsCommand(),
}
return app
}

82
cmd/fleetctl/hosts.go Normal file
View File

@ -0,0 +1,82 @@
package main
import (
"github.com/pkg/errors"
"github.com/urfave/cli/v2"
)
const (
hostsFlagName = "hosts"
labelFlagName = "label"
statusFlagName = "status"
searchQueryFlagName = "search_query"
)
func hostsCommand() *cli.Command {
return &cli.Command{
Name: "hosts",
Usage: "Manage Fleet hosts",
Subcommands: []*cli.Command{
transferCommand(),
},
}
}
func transferCommand() *cli.Command {
return &cli.Command{
Name: "transfer",
Usage: "Transfer one or more hosts to a team",
UsageText: `This command will gather the set of hosts specified and transfer them to the team.`,
Flags: []cli.Flag{
&cli.StringFlag{
Name: teamFlagName,
Usage: "Team name hosts will be transferred to",
Required: true,
},
&cli.StringSliceFlag{
Name: hostsFlagName,
Usage: "Comma separated hostnames to transfer",
},
&cli.StringFlag{
Name: labelFlagName,
Usage: "Label name to transfer",
},
&cli.StringFlag{
Name: statusFlagName,
Usage: "Status to use when filtering hosts",
},
&cli.StringFlag{
Name: searchQueryFlagName,
Usage: "A search query that returns matching hostnames to be transferred",
},
configFlag(),
contextFlag(),
yamlFlag(),
debugFlag(),
},
Action: func(c *cli.Context) error {
client, err := clientFromCLI(c)
if err != nil {
return err
}
team := c.String(teamFlagName)
hosts := c.StringSlice(hostsFlagName)
label := c.String(labelFlagName)
status := c.String(statusFlagName)
searchQuery := c.String(searchQueryFlagName)
if hosts != nil {
if label != "" || searchQuery != "" || status != "" {
return errors.New("--hosts cannot be used along side any other flag")
}
} else {
if label == "" && searchQuery == "" && status == "" {
return errors.New("You need to define either --hosts, or one or more of --label, --status, --search_query")
}
}
return client.TransferHosts(hosts, label, status, searchQuery, team)
},
}
}

153
cmd/fleetctl/hosts_test.go Normal file
View File

@ -0,0 +1,153 @@
package main
import (
"testing"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestHostTransferFlagChecks(t *testing.T) {
server, _ := runServerWithMockedDS(t)
defer server.Close()
runAppCheckErr(t,
[]string{"hosts", "transfer", "--team", "team1", "--hosts", "host1", "--label", "AAA"},
"--hosts cannot be used along side any other flag",
)
runAppCheckErr(t,
[]string{"hosts", "transfer", "--team", "team1"},
"You need to define either --hosts, or one or more of --label, --status, --search_query",
)
}
func TestHostsTransferByHosts(t *testing.T) {
server, ds := runServerWithMockedDS(t)
defer server.Close()
ds.HostByIdentifierFunc = func(identifier string) (*fleet.Host, error) {
require.Equal(t, "host1", identifier)
return &fleet.Host{ID: 42}, nil
}
ds.TeamByNameFunc = func(name string) (*fleet.Team, error) {
require.Equal(t, "team1", name)
return &fleet.Team{ID: 99, Name: "team1"}, nil
}
ds.AddHostsToTeamFunc = func(teamID *uint, hostIDs []uint) error {
require.NotNil(t, teamID)
require.Equal(t, uint(99), *teamID)
require.Equal(t, []uint{42}, hostIDs)
return nil
}
assert.Equal(t, "", runAppForTest(t, []string{"hosts", "transfer", "--team", "team1", "--hosts", "host1"}))
}
func TestHostsTransferByLabel(t *testing.T) {
server, ds := runServerWithMockedDS(t)
defer server.Close()
ds.HostByIdentifierFunc = func(identifier string) (*fleet.Host, error) {
require.Equal(t, "host1", identifier)
return &fleet.Host{ID: 42}, nil
}
ds.TeamByNameFunc = func(name string) (*fleet.Team, error) {
require.Equal(t, "team1", name)
return &fleet.Team{ID: 99, Name: "team1"}, nil
}
ds.LabelIDsByNameFunc = func(labels []string) ([]uint, error) {
require.Equal(t, []string{"label1"}, labels)
return []uint{uint(11)}, nil
}
ds.ListHostsInLabelFunc = func(filter fleet.TeamFilter, lid uint, opt fleet.HostListOptions) ([]*fleet.Host, error) {
require.Equal(t, fleet.HostStatus(""), opt.StatusFilter)
require.Equal(t, uint(11), lid)
return []*fleet.Host{{ID: 32}, {ID: 12}}, nil
}
ds.AddHostsToTeamFunc = func(teamID *uint, hostIDs []uint) error {
require.NotNil(t, teamID)
require.Equal(t, uint(99), *teamID)
require.Equal(t, []uint{32, 12}, hostIDs)
return nil
}
assert.Equal(t, "", runAppForTest(t, []string{"hosts", "transfer", "--team", "team1", "--label", "label1"}))
}
func TestHostsTransferByStatus(t *testing.T) {
server, ds := runServerWithMockedDS(t)
defer server.Close()
ds.HostByIdentifierFunc = func(identifier string) (*fleet.Host, error) {
require.Equal(t, "host1", identifier)
return &fleet.Host{ID: 42}, nil
}
ds.TeamByNameFunc = func(name string) (*fleet.Team, error) {
require.Equal(t, "team1", name)
return &fleet.Team{ID: 99, Name: "team1"}, nil
}
ds.LabelIDsByNameFunc = func(labels []string) ([]uint, error) {
require.Equal(t, []string{"label1"}, labels)
return []uint{uint(11)}, nil
}
ds.ListHostsFunc = func(filter fleet.TeamFilter, opt fleet.HostListOptions) ([]*fleet.Host, error) {
require.Equal(t, fleet.StatusOnline, opt.StatusFilter)
return []*fleet.Host{{ID: 32}, {ID: 12}}, nil
}
ds.AddHostsToTeamFunc = func(teamID *uint, hostIDs []uint) error {
require.NotNil(t, teamID)
require.Equal(t, uint(99), *teamID)
require.Equal(t, []uint{32, 12}, hostIDs)
return nil
}
assert.Equal(t, "", runAppForTest(t,
[]string{"hosts", "transfer", "--team", "team1", "--status", "online"}))
}
func TestHostsTransferByStatusAndSearchQuery(t *testing.T) {
server, ds := runServerWithMockedDS(t)
defer server.Close()
ds.HostByIdentifierFunc = func(identifier string) (*fleet.Host, error) {
require.Equal(t, "host1", identifier)
return &fleet.Host{ID: 42}, nil
}
ds.TeamByNameFunc = func(name string) (*fleet.Team, error) {
require.Equal(t, "team1", name)
return &fleet.Team{ID: 99, Name: "team1"}, nil
}
ds.LabelIDsByNameFunc = func(labels []string) ([]uint, error) {
require.Equal(t, []string{"label1"}, labels)
return []uint{uint(11)}, nil
}
ds.ListHostsFunc = func(filter fleet.TeamFilter, opt fleet.HostListOptions) ([]*fleet.Host, error) {
require.Equal(t, fleet.StatusOnline, opt.StatusFilter)
require.Equal(t, "somequery", opt.MatchQuery)
return []*fleet.Host{{ID: 32}, {ID: 12}}, nil
}
ds.AddHostsToTeamFunc = func(teamID *uint, hostIDs []uint) error {
require.NotNil(t, teamID)
require.Equal(t, uint(99), *teamID)
require.Equal(t, []uint{32, 12}, hostIDs)
return nil
}
assert.Equal(t, "", runAppForTest(t,
[]string{"hosts", "transfer", "--team", "team1", "--status", "online", "--search_query", "somequery"}))
}

View File

@ -50,6 +50,19 @@ func runServerWithMockedDS(t *testing.T, opts ...service.TestServerOpts) (*httpt
}
func runAppForTest(t *testing.T, args []string) string {
w, exitErr, err := runAppNoChecks(args)
require.Nil(t, err)
require.Nil(t, exitErr)
return w.String()
}
func runAppCheckErr(t *testing.T, args []string, errorMsg string) string {
w, _, err := runAppNoChecks(args)
require.Equal(t, errorMsg, err.Error())
return w.String()
}
func runAppNoChecks(args []string) (*bytes.Buffer, error, error) {
w := new(bytes.Buffer)
r, _, _ := os.Pipe()
var exitErr error
@ -57,7 +70,5 @@ func runAppForTest(t *testing.T, args []string) string {
exitErr = err
})
err := app.Run(append([]string{""}, args...))
require.Nil(t, err)
require.Nil(t, exitErr)
return w.String()
return w, exitErr, err
}

View File

@ -31,6 +31,7 @@ func userCommand() *cli.Command {
Usage: "Manage Fleet users",
Subcommands: []*cli.Command{
createUserCommand(),
deleteUserCommand(),
},
}
}
@ -171,3 +172,31 @@ func createUserCommand() *cli.Command {
},
}
}
func deleteUserCommand() *cli.Command {
return &cli.Command{
Name: "delete",
Usage: "Delete a user",
UsageText: `This command will delete a user specified by their email in Fleet.`,
Flags: []cli.Flag{
&cli.StringFlag{
Name: emailFlagName,
Usage: "Email for user (required)",
Required: true,
},
configFlag(),
contextFlag(),
yamlFlag(),
debugFlag(),
},
Action: func(c *cli.Context) error {
client, err := clientFromCLI(c)
if err != nil {
return err
}
email := c.String(emailFlagName)
return client.DeleteUser(email)
},
}
}

View File

@ -0,0 +1,31 @@
package main
import (
"testing"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/stretchr/testify/assert"
)
func TestUserDelete(t *testing.T) {
server, ds := runServerWithMockedDS(t)
defer server.Close()
ds.UserByEmailFunc = func(email string) (*fleet.User, error) {
return &fleet.User{
ID: 42,
Name: "test1",
Email: "user1@test.com",
}, nil
}
deletedUser := uint(0)
ds.DeleteUserFunc = func(id uint) error {
deletedUser = id
return nil
}
assert.Equal(t, "", runAppForTest(t, []string{"user", "delete", "--email", "user1@test.com"}))
assert.Equal(t, uint(42), deletedUser)
}

View File

@ -15,6 +15,7 @@
- [Fleet configuration](#fleet-configuration)
- [File carving](#file-carving)
- [Teams](#teams)
- [Translator](#translator)
## Overview
@ -5273,3 +5274,91 @@ _Available in Fleet Basic_
```
---
## Translator
### Translate IDs
`POST /api/v1/fleet/translate`
#### Parameters
| Name | Type | In | Description |
| --------------- | ------- | ----- | ---------------------------------------- |
| list | array | body | **Required** list of items to translate. |
#### Example
`POST /api/v1/fleet/translate`
##### Request body
```
{
"list": [
{
"type": "user",
"payload": {
"identifier": "some@email.com"
}
},
{
"type": "label",
"payload": {
"identifier": "labelA"
}
},
{
"type": "team",
"payload": {
"identifier": "team1"
}
},
{
"type": "host",
"payload": {
"identifier": "host-ABC"
}
},
]
}
```
##### Default response
`Status: 200`
```
{
"list": [
{
"type": "user",
"payload": {
"identifier": "some@email.com",
"id": 32
}
},
{
"type": "label",
"payload": {
"identifier": "labelA",
"id": 1
}
},
{
"type": "team",
"payload": {
"identifier": "team1",
"id": 22
}
},
{
"type": "host",
"payload": {
"identifier": "host-ABC",
"id": 45
}
},
]
}
```

View File

@ -213,6 +213,8 @@ const (
ErrNoRoleNeeded = 1
// ErrNoOneAdminNeeded is the error number when all admins are about to be removed
ErrNoOneAdminNeeded = 2
//ErrNoUnknownTranslate is returned when an item type in the translate payload is unknown
ErrNoUnknownTranslate = 3
)
// NewError returns a fleet error with the code and message specified

View File

@ -21,4 +21,5 @@ type Service interface {
ActivitiesService
UserRolesService
GlobalScheduleService
TranslatorService
}

View File

@ -0,0 +1,26 @@
package fleet
import (
"context"
)
const (
TranslatorTypeUserEmail = "user"
TranslatorTypeLabel = "label"
TranslatorTypeTeam = "team"
TranslatorTypeHost = "host"
)
type TranslatePayload struct {
Type string `json:"type"`
Payload StringIdentifierToIDPayload `json:"payload"`
}
type StringIdentifierToIDPayload struct {
Identifier string `json:"identifier"`
ID uint `json:"id"`
}
type TranslatorService interface {
Translate(ctx context.Context, payloads []TranslatePayload) ([]TranslatePayload, error)
}

View File

@ -5,6 +5,8 @@ import (
"fmt"
"net/http"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/fleetdm/fleet/v4/server/ptr"
"github.com/pkg/errors"
)
@ -97,3 +99,90 @@ func (c *Client) DeleteHost(id uint) error {
return nil
}
func (c *Client) translateTransferHostsToIDs(hosts []string, label string, team string) ([]uint, uint, uint, error) {
verb, path := "POST", "/api/v1/fleet/translate"
var responseBody translatorResponse
var translatePayloads []fleet.TranslatePayload
for _, host := range hosts {
translatedPayload, err := encodeTranslatedPayload(fleet.TranslatorTypeHost, host)
if err != nil {
return nil, 0, 0, err
}
translatePayloads = append(translatePayloads, translatedPayload)
}
if label != "" {
translatedPayload, err := encodeTranslatedPayload(fleet.TranslatorTypeLabel, label)
if err != nil {
return nil, 0, 0, err
}
translatePayloads = append(translatePayloads, translatedPayload)
}
translatedPayload, err := encodeTranslatedPayload(fleet.TranslatorTypeTeam, team)
if err != nil {
return nil, 0, 0, err
}
translatePayloads = append(translatePayloads, translatedPayload)
params := translatorRequest{List: translatePayloads}
err = c.authenticatedRequest(&params, verb, path, &responseBody)
if err != nil {
return nil, 0, 0, err
}
var hostIDs []uint
var labelID uint
var teamID uint
for _, payload := range responseBody.List {
switch payload.Type {
case fleet.TranslatorTypeLabel:
labelID = payload.Payload.ID
case fleet.TranslatorTypeTeam:
teamID = payload.Payload.ID
case fleet.TranslatorTypeHost:
hostIDs = append(hostIDs, payload.Payload.ID)
}
}
return hostIDs, labelID, teamID, nil
}
func encodeTranslatedPayload(translatorType string, identifier string) (fleet.TranslatePayload, error) {
translatedPayload := fleet.TranslatePayload{
Type: translatorType,
Payload: fleet.StringIdentifierToIDPayload{Identifier: identifier},
}
return translatedPayload, nil
}
func (c *Client) TransferHosts(hosts []string, label string, status, searchQuery string, team string) error {
hostIDs, labelID, teamID, err := c.translateTransferHostsToIDs(hosts, label, team)
if err != nil {
return err
}
if len(hosts) != 0 {
verb, path := "POST", "/api/v1/fleet/hosts/transfer"
var responseBody addHostsToTeamResponse
params := addHostsToTeamRequest{TeamID: ptr.Uint(teamID), HostIDs: hostIDs}
return c.authenticatedRequest(params, verb, path, &responseBody)
}
var labelIDPtr *uint
if label != "" {
labelIDPtr = &labelID
}
verb, path := "POST", "/api/v1/fleet/hosts/transfer/filter"
var responseBody addHostsToTeamByFilterResponse
params := addHostsToTeamByFilterRequest{TeamID: ptr.Uint(teamID), Filters: struct {
MatchQuery string `json:"query"`
Status fleet.HostStatus `json:"status"`
LabelID *uint `json:"label_id"`
}{MatchQuery: searchQuery, Status: fleet.HostStatus(status), LabelID: labelIDPtr}}
return c.authenticatedRequest(params, verb, path, &responseBody)
}

View File

@ -1,7 +1,10 @@
package service
import (
"fmt"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/pkg/errors"
)
// CreateUser creates a new user, skipping the invitation process.
@ -31,3 +34,36 @@ func (c *Client) ApplyUsersRoleSecretSpec(spec *fleet.UsersRoleSpec) error {
var responseBody applyUserRoleSpecsResponse
return c.authenticatedRequest(req, verb, path, &responseBody)
}
func (c *Client) userIdFromEmail(email string) (uint, error) {
verb, path := "POST", "/api/v1/fleet/translate"
var responseBody translatorResponse
params := translatorRequest{List: []fleet.TranslatePayload{
{
Type: fleet.TranslatorTypeUserEmail,
Payload: fleet.StringIdentifierToIDPayload{Identifier: email},
},
}}
err := c.authenticatedRequest(&params, verb, path, &responseBody)
if err != nil {
return 0, err
}
if len(responseBody.List) != 1 {
return 0, errors.New("Expected 1 item translated, got none")
}
return responseBody.List[0].Payload.ID, nil
}
// DeleteUser deletes the user specified by the email
func (c *Client) DeleteUser(email string) error {
userID, err := c.userIdFromEmail(email)
if err != nil {
return err
}
verb, path := "DELETE", fmt.Sprintf("/api/v1/fleet/users/%d", userID)
var responseBody deleteUserResponse
return c.authenticatedRequest(nil, verb, path, &responseBody)
}

View File

@ -668,6 +668,7 @@ func attachFleetAPIRoutes(r *mux.Router, h *fleetHandlers) {
func attachNewStyleFleetAPIRoutes(r *mux.Router, svc fleet.Service, opts []kithttp.ServerOption) {
handle("POST", "/api/v1/fleet/users/roles/spec", makeApplyUserRoleSpecsEndpoint(svc, opts), "apply_user_roles_spec", r)
handle("POST", "/api/v1/fleet/translate", makeTranslatorEndpoint(svc, opts), "translator", r)
handle("POST", "/api/v1/fleet/spec/teams", makeApplyTeamSpecsEndpoint(svc, opts), "apply_team_specs", r)
}

View File

@ -415,3 +415,25 @@ func TestTeamSpecs(t *testing.T) {
require.Len(t, team.Secrets, 1)
assert.Equal(t, "ABC", team.Secrets[0].Secret)
}
func TestTranslator(t *testing.T) {
ds := mysql.CreateMySQLDS(t)
defer ds.Close()
users, server := RunServerForTestsWithDS(t, ds)
token := getTestAdminToken(t, server)
payload := translatorResponse{}
params := translatorRequest{List: []fleet.TranslatePayload{
{
Type: fleet.TranslatorTypeUserEmail,
Payload: fleet.StringIdentifierToIDPayload{Identifier: "admin1@example.com"},
},
}}
doJSONReq(t, &params, "POST", server, "/api/v1/fleet/translate", token, http.StatusOK, &payload)
require.Nil(t, payload.Err)
assert.Len(t, payload.List, 1)
assert.Equal(t, users[payload.List[0].Payload.Identifier].ID, payload.List[0].Payload.ID)
}

View File

@ -0,0 +1,125 @@
package service
import (
"context"
"net/http"
"time"
"github.com/fleetdm/fleet/v4/server/fleet"
kithttp "github.com/go-kit/kit/transport/http"
)
type translatorRequest struct {
List []fleet.TranslatePayload `json:"list"`
}
type translatorResponse struct {
List []fleet.TranslatePayload `json:"list"`
Err error `json:"error,omitempty"`
}
func (r translatorResponse) error() error { return r.Err }
func makeTranslatorEndpoint(svc fleet.Service, opts []kithttp.ServerOption) http.Handler {
return newServer(
makeAuthenticatedServiceEndpoint(svc, translatorEndpoint),
makeDecoderForType(translatorRequest{}),
opts,
)
}
func translatorEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (interface{}, error) {
req := request.(*translatorRequest)
resp, err := svc.Translate(ctx, req.List)
if err != nil {
return translatorResponse{Err: err}, nil
}
return translatorResponse{List: resp}, nil
}
type translateFunc func(ds fleet.Datastore, identifier string) (uint, error)
func translateEmailToUserID(ds fleet.Datastore, identifier string) (uint, error) {
user, err := ds.UserByEmail(identifier)
if err != nil {
return 0, err
}
return user.ID, nil
}
func translateLabelToID(ds fleet.Datastore, identifier string) (uint, error) {
labelIDs, err := ds.LabelIDsByName([]string{identifier})
if err != nil {
return 0, err
}
return labelIDs[0], nil
}
func translateTeamToID(ds fleet.Datastore, identifier string) (uint, error) {
team, err := ds.TeamByName(identifier)
if err != nil {
return 0, err
}
return team.ID, nil
}
func translateHostToID(ds fleet.Datastore, identifier string) (uint, error) {
host, err := ds.HostByIdentifier(identifier)
if err != nil {
return 0, err
}
return host.ID, nil
}
func (svc Service) Translate(ctx context.Context, payloads []fleet.TranslatePayload) ([]fleet.TranslatePayload, error) {
var finalPayload []fleet.TranslatePayload
for _, payload := range payloads {
var translateFunc translateFunc
switch payload.Type {
case fleet.TranslatorTypeUserEmail:
if err := svc.authz.Authorize(ctx, &fleet.User{}, fleet.ActionRead); err != nil {
return nil, err
}
translateFunc = translateEmailToUserID
case fleet.TranslatorTypeLabel:
if err := svc.authz.Authorize(ctx, &fleet.Label{}, fleet.ActionRead); err != nil {
return nil, err
}
translateFunc = translateLabelToID
case fleet.TranslatorTypeTeam:
if err := svc.authz.Authorize(ctx, &fleet.Team{}, fleet.ActionRead); err != nil {
return nil, err
}
translateFunc = translateTeamToID
case fleet.TranslatorTypeHost:
if err := svc.authz.Authorize(ctx, &fleet.Host{}, fleet.ActionRead); err != nil {
return nil, err
}
translateFunc = translateHostToID
default:
return nil, fleet.NewErrorf(fleet.ErrNoUnknownTranslate, "Type %s is unknown.", payload.Type)
}
id, err := translateFunc(svc.ds, payload.Payload.Identifier)
if err != nil {
return nil, err
}
payload.Payload.ID = id
finalPayload = append(finalPayload, fleet.TranslatePayload{
Type: payload.Type,
Payload: payload.Payload,
})
}
return finalPayload, nil
}
func (mw loggingMiddleware) Translate(ctx context.Context, payloads []fleet.TranslatePayload) ([]fleet.TranslatePayload, error) {
var err error
defer func(begin time.Time) {
_ = mw.loggerDebug(err).Log("method", "Translate", "err", err, "took", time.Since(begin))
}(time.Now())
return mw.Service.Translate(ctx, payloads)
}