From 484c6153e352faaefb494b3f70677e7bfa4208d9 Mon Sep 17 00:00:00 2001 From: Tomas Touceda Date: Wed, 21 Jul 2021 14:03:10 -0300 Subject: [PATCH] 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 Co-authored-by: Zach Wasserman --- changes/issue-1359-fleetctl-host-transfer | 1 + changes/issue-1360-fleetctl-user-delete | 1 + cmd/fleetctl/fleetctl.go | 1 + cmd/fleetctl/hosts.go | 82 ++++++++++++ cmd/fleetctl/hosts_test.go | 153 ++++++++++++++++++++++ cmd/fleetctl/testing_utils.go | 17 ++- cmd/fleetctl/user.go | 29 ++++ cmd/fleetctl/users_test.go | 31 +++++ docs/1-Using-Fleet/3-REST-API.md | 89 +++++++++++++ server/fleet/errors.go | 2 + server/fleet/service.go | 1 + server/fleet/translator.go | 26 ++++ server/service/client_hosts.go | 89 +++++++++++++ server/service/client_users.go | 36 +++++ server/service/handler.go | 1 + server/service/integration_test.go | 22 ++++ server/service/translator.go | 125 ++++++++++++++++++ 17 files changed, 703 insertions(+), 3 deletions(-) create mode 100644 changes/issue-1359-fleetctl-host-transfer create mode 100644 changes/issue-1360-fleetctl-user-delete create mode 100644 cmd/fleetctl/hosts.go create mode 100644 cmd/fleetctl/hosts_test.go create mode 100644 cmd/fleetctl/users_test.go create mode 100644 server/fleet/translator.go create mode 100644 server/service/translator.go diff --git a/changes/issue-1359-fleetctl-host-transfer b/changes/issue-1359-fleetctl-host-transfer new file mode 100644 index 0000000000..9eac68a888 --- /dev/null +++ b/changes/issue-1359-fleetctl-host-transfer @@ -0,0 +1 @@ +* Add host transfer capabilities to fleetctl. Fixes issue 1359. \ No newline at end of file diff --git a/changes/issue-1360-fleetctl-user-delete b/changes/issue-1360-fleetctl-user-delete new file mode 100644 index 0000000000..6b101a260c --- /dev/null +++ b/changes/issue-1360-fleetctl-user-delete @@ -0,0 +1 @@ +* Add user delete capabilities to fleetctl. Fixes issue 1360 \ No newline at end of file diff --git a/cmd/fleetctl/fleetctl.go b/cmd/fleetctl/fleetctl.go index cc7e7d5c32..90e49c3f23 100644 --- a/cmd/fleetctl/fleetctl.go +++ b/cmd/fleetctl/fleetctl.go @@ -60,6 +60,7 @@ func createApp(reader io.Reader, writer io.Writer, exitErrHandler cli.ExitErrHan debugCommand(), previewCommand(), eefleetctl.UpdatesCommand(), + hostsCommand(), } return app } diff --git a/cmd/fleetctl/hosts.go b/cmd/fleetctl/hosts.go new file mode 100644 index 0000000000..f80a821bb2 --- /dev/null +++ b/cmd/fleetctl/hosts.go @@ -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) + }, + } +} diff --git a/cmd/fleetctl/hosts_test.go b/cmd/fleetctl/hosts_test.go new file mode 100644 index 0000000000..2d66ec1f5d --- /dev/null +++ b/cmd/fleetctl/hosts_test.go @@ -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"})) +} diff --git a/cmd/fleetctl/testing_utils.go b/cmd/fleetctl/testing_utils.go index c7ab5667af..8aa80d2b32 100644 --- a/cmd/fleetctl/testing_utils.go +++ b/cmd/fleetctl/testing_utils.go @@ -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 } diff --git a/cmd/fleetctl/user.go b/cmd/fleetctl/user.go index 389da321e6..d5663ac1de 100644 --- a/cmd/fleetctl/user.go +++ b/cmd/fleetctl/user.go @@ -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) + }, + } +} diff --git a/cmd/fleetctl/users_test.go b/cmd/fleetctl/users_test.go new file mode 100644 index 0000000000..7c71b9f581 --- /dev/null +++ b/cmd/fleetctl/users_test.go @@ -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) +} diff --git a/docs/1-Using-Fleet/3-REST-API.md b/docs/1-Using-Fleet/3-REST-API.md index 7d30519ab5..f6cd8b9cb5 100644 --- a/docs/1-Using-Fleet/3-REST-API.md +++ b/docs/1-Using-Fleet/3-REST-API.md @@ -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 + } + }, + ] +} +``` diff --git a/server/fleet/errors.go b/server/fleet/errors.go index 7f57362f5f..7be4b4dead 100644 --- a/server/fleet/errors.go +++ b/server/fleet/errors.go @@ -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 diff --git a/server/fleet/service.go b/server/fleet/service.go index ae96abaaf1..d9ebfc71c8 100644 --- a/server/fleet/service.go +++ b/server/fleet/service.go @@ -21,4 +21,5 @@ type Service interface { ActivitiesService UserRolesService GlobalScheduleService + TranslatorService } diff --git a/server/fleet/translator.go b/server/fleet/translator.go new file mode 100644 index 0000000000..6807c7f132 --- /dev/null +++ b/server/fleet/translator.go @@ -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) +} diff --git a/server/service/client_hosts.go b/server/service/client_hosts.go index db9760de99..9ba47afcc0 100644 --- a/server/service/client_hosts.go +++ b/server/service/client_hosts.go @@ -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(¶ms, 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) +} diff --git a/server/service/client_users.go b/server/service/client_users.go index 83b98f3b16..9996b712a2 100644 --- a/server/service/client_users.go +++ b/server/service/client_users.go @@ -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(¶ms, 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) +} diff --git a/server/service/handler.go b/server/service/handler.go index 117b9bb604..8a495ed932 100644 --- a/server/service/handler.go +++ b/server/service/handler.go @@ -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) } diff --git a/server/service/integration_test.go b/server/service/integration_test.go index 09c5b7c3c0..ea754a13e0 100644 --- a/server/service/integration_test.go +++ b/server/service/integration_test.go @@ -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, ¶ms, "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) +} diff --git a/server/service/translator.go b/server/service/translator.go new file mode 100644 index 0000000000..a5609f382c --- /dev/null +++ b/server/service/translator.go @@ -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) +}