From 3286864d9d17481eef6824ee35cf61231a61ac6a Mon Sep 17 00:00:00 2001 From: Zach Wasserman Date: Wed, 17 Mar 2021 21:59:00 -0700 Subject: [PATCH] Initial work on user team information storage and retrieval (#483) There are more migrations to come, but this is a foundation for the DB changes that will be needed for Teams. --- server/datastore/datastore_teams_test.go | 39 ++++++ server/datastore/datastore_test.go | 3 + server/datastore/datastore_users_test.go | 112 ++++++++++++++++++ .../20210315111056_CreateTeamsTables.go | 54 +++++++++ server/datastore/mysql/teams.go | 69 +++++++++++ server/datastore/mysql/users.go | 96 +++++++++++++++ server/kolide/datastore.go | 2 + server/kolide/hosts.go | 4 + server/kolide/service.go | 1 + server/kolide/teams.go | 56 +++++++++ server/kolide/users.go | 45 ++++--- server/mock/datastore.go | 1 + server/service/endpoint_teams.go | 64 ++++++++++ server/service/handler.go | 11 ++ server/service/service_teams.go | 47 ++++++++ server/service/service_users.go | 4 + server/service/transport_teams.go | 29 +++++ 17 files changed, 623 insertions(+), 14 deletions(-) create mode 100644 server/datastore/datastore_teams_test.go create mode 100644 server/datastore/mysql/migrations/tables/20210315111056_CreateTeamsTables.go create mode 100644 server/datastore/mysql/teams.go create mode 100644 server/kolide/teams.go create mode 100644 server/service/endpoint_teams.go create mode 100644 server/service/service_teams.go create mode 100644 server/service/transport_teams.go diff --git a/server/datastore/datastore_teams_test.go b/server/datastore/datastore_teams_test.go new file mode 100644 index 0000000000..46c582920e --- /dev/null +++ b/server/datastore/datastore_teams_test.go @@ -0,0 +1,39 @@ +package datastore + +import ( + "testing" + + "github.com/fleetdm/fleet/server/kolide" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func testTeamGetSet(t *testing.T, ds kolide.Datastore) { + var createTests = []struct { + name, description string + }{ + {"foo_team", "foobar is the description"}, + {"bar_team", "were you hoping for more?"}, + } + + for _, tt := range createTests { + t.Run("", func(t *testing.T) { + team, err := ds.NewTeam(&kolide.Team{ + Name: tt.name, + Description: tt.description, + }) + require.NoError(t, err) + assert.NotZero(t, team.ID) + + team, err = ds.Team(team.ID) + require.NoError(t, err) + assert.Equal(t, tt.name, team.Name) + assert.Equal(t, tt.description, team.Description) + + team, err = ds.TeamByName(tt.name) + require.NoError(t, err) + assert.Equal(t, tt.name, team.Name) + assert.Equal(t, tt.description, team.Description) + }) + } +} diff --git a/server/datastore/datastore_test.go b/server/datastore/datastore_test.go index ee162d3275..5e6225080e 100644 --- a/server/datastore/datastore_test.go +++ b/server/datastore/datastore_test.go @@ -94,4 +94,7 @@ var testFunctions = [...]func(*testing.T, kolide.Datastore){ testCarveListCarves, testCarveCleanupCarves, testCarveUpdateCarve, + testTeamGetSet, + testUserTeams, + testUserCreateWithTeams, } diff --git a/server/datastore/datastore_users_test.go b/server/datastore/datastore_users_test.go index b3c755f120..28ab885401 100644 --- a/server/datastore/datastore_users_test.go +++ b/server/datastore/datastore_users_test.go @@ -145,3 +145,115 @@ func testListUsers(t *testing.T, ds kolide.Datastore) { require.Len(t, users, 1) assert.Equal(t, "mike@kolide.co", users[0].Email) } + +func testUserTeams(t *testing.T, ds kolide.Datastore) { + for i := 0; i < 10; i++ { + _, err := ds.NewTeam(&kolide.Team{Name: fmt.Sprintf("%d", i)}) + require.NoError(t, err) + } + + users := createTestUsers(t, ds) + + assert.Len(t, users[0].Teams, 0) + assert.Len(t, users[1].Teams, 0) + + // Add invalid team should fail + users[0].Teams = []kolide.UserTeam{ + { + Team: kolide.Team{ID: 13}, + Role: "foobar", + }, + } + err := ds.SaveUser(users[0]) + require.Error(t, err) + + // Add valid team should succeed + users[0].Teams = []kolide.UserTeam{ + { + Team: kolide.Team{ID: 3}, + Role: "foobar", + }, + } + err = ds.SaveUser(users[0]) + require.NoError(t, err) + + users, err = ds.ListUsers(kolide.ListOptions{OrderKey: "name", OrderDirection: kolide.OrderDescending}) + require.NoError(t, err) + + assert.Len(t, users[0].Teams, 1) + assert.Len(t, users[1].Teams, 0) + + users[1].Teams = []kolide.UserTeam{ + { + Team: kolide.Team{ID: 1}, + Role: "foobar", + }, + { + Team: kolide.Team{ID: 2}, + Role: "foobar", + }, + { + Team: kolide.Team{ID: 3}, + Role: "foobar", + }, + } + err = ds.SaveUser(users[1]) + require.NoError(t, err) + + users, err = ds.ListUsers(kolide.ListOptions{OrderKey: "name", OrderDirection: kolide.OrderDescending}) + require.NoError(t, err) + + assert.Len(t, users[0].Teams, 1) + assert.Len(t, users[1].Teams, 3) + + // Clear teams + users[1].Teams = []kolide.UserTeam{} + err = ds.SaveUser(users[1]) + require.NoError(t, err) + + users, err = ds.ListUsers(kolide.ListOptions{OrderKey: "name", OrderDirection: kolide.OrderDescending}) + require.NoError(t, err) + + assert.Len(t, users[0].Teams, 1) + assert.Len(t, users[1].Teams, 0) +} + +func testUserCreateWithTeams(t *testing.T, ds kolide.Datastore) { + for i := 0; i < 10; i++ { + _, err := ds.NewTeam(&kolide.Team{Name: fmt.Sprintf("%d", i)}) + require.NoError(t, err) + } + + u := &kolide.User{ + Username: "1", + Password: []byte("foo"), + Teams: []kolide.UserTeam{ + { + Team: kolide.Team{ID: 6}, + Role: "admin", + }, + { + Team: kolide.Team{ID: 3}, + Role: "observer", + }, + { + Team: kolide.Team{ID: 9}, + Role: "maintainer", + }, + }, + } + user, err := ds.NewUser(u) + assert.Nil(t, err) + assert.Len(t, user.Teams, 3) + + user, err = ds.UserByID(user.ID) + require.NoError(t, err) + assert.Len(t, user.Teams, 3) + + assert.Equal(t, uint(3), user.Teams[0].ID) + assert.Equal(t, "observer", user.Teams[0].Role) + assert.Equal(t, uint(6), user.Teams[1].ID) + assert.Equal(t, "admin", user.Teams[1].Role) + assert.Equal(t, uint(9), user.Teams[2].ID) + assert.Equal(t, "maintainer", user.Teams[2].Role) +} diff --git a/server/datastore/mysql/migrations/tables/20210315111056_CreateTeamsTables.go b/server/datastore/mysql/migrations/tables/20210315111056_CreateTeamsTables.go new file mode 100644 index 0000000000..0aa59edd76 --- /dev/null +++ b/server/datastore/mysql/migrations/tables/20210315111056_CreateTeamsTables.go @@ -0,0 +1,54 @@ +package tables + +import ( + "database/sql" + + "github.com/pkg/errors" +) + +func init() { + MigrationClient.AddMigration(Up_20210315111056, Down_20210315111056) +} + +func Up_20210315111056(tx *sql.Tx) error { + if _, err := tx.Exec(`CREATE TABLE IF NOT EXISTS teams ( + id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + name VARCHAR(255) NOT NULL, + description VARCHAR(1023) NOT NULL DEFAULT '', + UNIQUE KEY idx_name (name) + )`); err != nil { + return errors.Wrap(err, "create teams") + } + + // Users <> Teams mapping + if _, err := tx.Exec(`CREATE TABLE IF NOT EXISTS user_teams ( + user_id INT UNSIGNED NOT NULL, + team_id INT UNSIGNED NOT NULL, + role VARCHAR(64) NOT NULL, + PRIMARY KEY (user_id, team_id), + FOREIGN KEY fk_user_id (user_id) REFERENCES users (id) ON DELETE CASCADE ON UPDATE CASCADE, + FOREIGN KEY fk_team_id (team_id) REFERENCES teams (id) ON DELETE CASCADE ON UPDATE CASCADE + )`); err != nil { + return errors.Wrap(err, "create user_teams") + } + + if _, err := tx.Exec(`ALTER TABLE hosts + ADD team_id INT UNSIGNED DEFAULT NULL, + ADD FOREIGN KEY fk_team_id (team_id) REFERENCES teams (id) ON DELETE SET NULL + `); err != nil { + return errors.Wrap(err, "alter hosts") + } + + if _, err := tx.Exec(`ALTER TABLE users + ADD global_role VARCHAR(64) DEFAULT NULL + `); err != nil { + return errors.Wrap(err, "alter users") + } + + return nil +} + +func Down_20210315111056(tx *sql.Tx) error { + return nil +} diff --git a/server/datastore/mysql/teams.go b/server/datastore/mysql/teams.go new file mode 100644 index 0000000000..be30a3fe27 --- /dev/null +++ b/server/datastore/mysql/teams.go @@ -0,0 +1,69 @@ +package mysql + +import ( + "github.com/fleetdm/fleet/server/kolide" + "github.com/pkg/errors" +) + +func (d *Datastore) NewTeam(team *kolide.Team) (*kolide.Team, error) { + query := ` + INSERT INTO teams ( + name, + description + ) VALUES ( ?, ? ) + ` + result, err := d.db.Exec( + query, + team.Name, + team.Description, + ) + if err != nil { + return nil, errors.Wrap(err, "insert team") + } + + id, _ := result.LastInsertId() + team.ID = uint(id) + return team, nil +} + +func (d *Datastore) Team(tid uint) (*kolide.Team, error) { + sql := ` + SELECT * FROM teams + WHERE id = ? + ` + team := &kolide.Team{} + + if err := d.db.Get(team, sql, tid); err != nil { + return nil, errors.Wrap(err, "select team") + } + + return team, nil +} + +func (d *Datastore) TeamByName(name string) (*kolide.Team, error) { + sql := ` + SELECT * FROM teams + WHERE name = ? + ` + team := &kolide.Team{} + + if err := d.db.Get(team, sql, name); err != nil { + return nil, errors.Wrap(err, "select team") + } + + return team, nil +} + +func (d *Datastore) SaveTeam(team *kolide.Team) (*kolide.Team, error) { + query := ` + UPDATE teams SET + name = ?, + description = ? + WHERE id = ? + ` + _, err := d.db.Exec(query, team.Name, team.Description, team.ID) + if err != nil { + return nil, errors.Wrap(err, "saving team") + } + return team, nil +} diff --git a/server/datastore/mysql/users.go b/server/datastore/mysql/users.go index 7dae0bc170..d80076e69a 100644 --- a/server/datastore/mysql/users.go +++ b/server/datastore/mysql/users.go @@ -3,8 +3,10 @@ package mysql import ( "database/sql" "fmt" + "strings" "github.com/fleetdm/fleet/server/kolide" + "github.com/jmoiron/sqlx" "github.com/pkg/errors" ) @@ -36,6 +38,11 @@ func (d *Datastore) NewUser(user *kolide.User) (*kolide.User, error) { id, _ := result.LastInsertId() user.ID = uint(id) + + if err := d.saveTeamsForUser(user); err != nil { + return nil, err + } + return user, nil } @@ -56,6 +63,10 @@ func (d *Datastore) findUser(searchCol string, searchVal interface{}) (*kolide.U return nil, errors.Wrap(err, "find user") } + if err := d.loadTeamsForUsers([]*kolide.User{user}); err != nil { + return nil, errors.Wrap(err, "load teams") + } + return user, nil } @@ -80,6 +91,10 @@ func (d *Datastore) ListUsers(opt kolide.ListOptions) ([]*kolide.User, error) { return nil, errors.Wrap(err, "list users") } + if err := d.loadTeamsForUsers(users); err != nil { + return nil, errors.Wrap(err, "load teams") + } + return users, nil } @@ -122,5 +137,86 @@ func (d *Datastore) SaveUser(user *kolide.User) error { return notFound("User").WithID(user.ID) } + // REVIEW: Check if teams have been set? + if err := d.saveTeamsForUser(user); err != nil { + return err + } + + return nil +} + +// loadTeamsForUsers will load the teams/roles for the provided users. +func (d *Datastore) loadTeamsForUsers(users []*kolide.User) error { + userIDs := make([]uint, 0, len(users)+1) + // Make sure the slice is never empty for IN by filling a nonexistent ID + userIDs = append(userIDs, 0) + idToUser := make(map[uint]*kolide.User, len(users)) + for _, u := range users { + // Initialize empty slice so we get an array in JSON responses instead + // of null if it is empty + u.Teams = []kolide.UserTeam{} + // Track IDs for queries and matching + userIDs = append(userIDs, u.ID) + idToUser[u.ID] = u + } + + sql := ` + SELECT ut.team_id AS id, ut.user_id, ut.role, t.name + FROM user_teams ut INNER JOIN teams t ON ut.team_id = t.id + WHERE ut.user_id IN (?) + ORDER BY user_id, team_id + ` + sql, args, err := sqlx.In(sql, userIDs) + if err != nil { + return errors.Wrap(err, "sqlx.In loadTeamsForUsers") + } + + var rows []struct { + kolide.UserTeam + UserID uint `db:"user_id"` + } + if err := d.db.Select(&rows, sql, args...); err != nil { + return errors.Wrap(err, "get loadTeamsForUsers") + } + + // Map each row to the appropriate user + for _, r := range rows { + user := idToUser[r.UserID] + user.Teams = append(user.Teams, r.UserTeam) + } + + return nil +} + +func (d *Datastore) saveTeamsForUser(user *kolide.User) error { + // Do a full teams update by deleting existing teams and then inserting all + // the current teams in a single transaction. + if err := d.withRetryTxx(func(tx *sqlx.Tx) error { + // Delete before insert + sql := `DELETE FROM user_teams WHERE user_id = ?` + if _, err := tx.Exec(sql, user.ID); err != nil { + return errors.Wrap(err, "delete existing teams") + } + + if len(user.Teams) == 0 { + return nil + } + + // Bulk insert + const valueStr = "(?,?,?)," + var args []interface{} + for _, userTeam := range user.Teams { + args = append(args, user.ID, userTeam.Team.ID, userTeam.Role) + } + sql = "INSERT INTO user_teams (user_id, team_id, role) VALUES " + + strings.Repeat(valueStr, len(user.Teams)) + sql = strings.TrimSuffix(sql, ",") + if _, err := tx.Exec(sql, args...); err != nil { + return errors.Wrap(err, "insert teams") + } + return nil + }); err != nil { + return errors.Wrap(err, "save teams for user") + } return nil } diff --git a/server/kolide/datastore.go b/server/kolide/datastore.go index 8db4e97db2..aa7dee4607 100644 --- a/server/kolide/datastore.go +++ b/server/kolide/datastore.go @@ -16,6 +16,8 @@ type Datastore interface { ScheduledQueryStore OsqueryOptionsStore CarveStore + TeamStore + Name() string Drop() error // MigrateTables creates and migrates the table schemas diff --git a/server/kolide/hosts.go b/server/kolide/hosts.go index 5df7cad0cc..0b109fbe68 100644 --- a/server/kolide/hosts.go +++ b/server/kolide/hosts.go @@ -6,6 +6,8 @@ import ( "encoding/base64" "encoding/json" "time" + + "gopkg.in/guregu/null.v3" ) type HostStatus string @@ -139,6 +141,8 @@ type Host struct { LoggerTLSPeriod uint `json:"logger_tls_period" db:"logger_tls_period"` Additional *json.RawMessage `json:"additional,omitempty" db:"additional"` EnrollSecretName string `json:"enroll_secret_name" db:"enroll_secret_name"` + + TeamID null.Int `json:"team_id" db:"team_id"` } // HostDetail provides the full host metadata along with associated labels and diff --git a/server/kolide/service.go b/server/kolide/service.go index 87a2ec63f3..265d06a65d 100644 --- a/server/kolide/service.go +++ b/server/kolide/service.go @@ -17,4 +17,5 @@ type Service interface { ScheduledQueryService StatusService CarveService + TeamService } diff --git a/server/kolide/teams.go b/server/kolide/teams.go new file mode 100644 index 0000000000..b393347767 --- /dev/null +++ b/server/kolide/teams.go @@ -0,0 +1,56 @@ +package kolide + +import ( + "context" + "time" +) + +type TeamStore interface { + // NewTeam creates a new Team object in the store. + NewTeam(team *Team) (*Team, error) + // Team retrieves the Team by ID. + Team(tid uint) (*Team, error) + // TeamByName retrieves the Team by Name. + TeamByName(name string) (*Team, error) + // SaveTeam saves any changes to the team. + SaveTeam(team *Team) (*Team, error) +} + +type TeamService interface { + NewTeam(ctx context.Context, p TeamPayload) (*Team, error) + ModifyTeam(ctx context.Context, id uint, payload TeamPayload) (*Team, error) +} + +type TeamPayload struct { + Name *string `json:"name"` + Description *string `json:"description"` +} + +// Team is the data representation for the "Team" concept (group of hosts and +// group of users that can perform operations on those hosts). +type Team struct { + // Directly in DB + + // ID is the database ID. + ID uint `json:"id" db:"id"` + // CreatedAt is the timestamp of the label creation. + CreatedAt time.Time `json:"created_at" db:"created_at"` + // Name is the human friendly name of the team. + Name string `json:"name" db:"name"` + // Description is an optional description for the team. + Description string `json:"description" db:"description"` + + // Derived from JOINs + + // Users is the users that have a role on this team. + Users []TeamUser `json:"users,omitempty"` + // Hosts are the hosts assigned to the team. + Hosts []Host `json:"hosts,omitempty"` +} + +type TeamUser struct { + // User is the user object + User + // Role is the role the user has for the team. + Role string `json:"role" db:"role"` +} diff --git a/server/kolide/users.go b/server/kolide/users.go index f00c282b8e..32615e4595 100644 --- a/server/kolide/users.go +++ b/server/kolide/users.go @@ -7,6 +7,7 @@ import ( "fmt" "golang.org/x/crypto/bcrypt" + "gopkg.in/guregu/null.v3" ) // UserStore contains methods for managing users in a datastore @@ -99,24 +100,36 @@ type User struct { AdminForcedPasswordReset bool `json:"force_password_reset" db:"admin_forced_password_reset"` GravatarURL string `json:"gravatar_url" db:"gravatar_url"` Position string `json:"position,omitempty"` // job role - // SSOEnabled if true, the single siqn on is used to log in - SSOEnabled bool `json:"sso_enabled" db:"sso_enabled"` + // SSOEnabled if true, the user may only log in via SSO + SSOEnabled bool `json:"sso_enabled" db:"sso_enabled"` + GlobalRole null.String `json:"global_role" db:"global_role"` + + // Teams is the teams this user has roles in. + Teams []UserTeam +} + +type UserTeam struct { + // Team is the team object. + Team + // Role is the role the user has for the team. + Role string `json:"role" db:"role"` } // UserPayload is used to modify an existing user type UserPayload struct { - Username *string `json:"username,omitempty"` - Name *string `json:"name,omitempty"` - Email *string `json:"email,omitempty"` - Admin *bool `json:"admin,omitempty"` - Enabled *bool `json:"enabled,omitempty"` - Password *string `json:"password,omitempty"` - GravatarURL *string `json:"gravatar_url,omitempty"` - Position *string `json:"position,omitempty"` - InviteToken *string `json:"invite_token,omitempty"` - SSOInvite *bool `json:"sso_invite,omitempty"` - SSOEnabled *bool `json:"sso_enabled,omitempty"` - AdminForcedPasswordReset *bool `json:"admin_forced_password_reset,omitempty"` + Username *string `json:"username,omitempty"` + Name *string `json:"name,omitempty"` + Email *string `json:"email,omitempty"` + Admin *bool `json:"admin,omitempty"` + Enabled *bool `json:"enabled,omitempty"` + Password *string `json:"password,omitempty"` + GravatarURL *string `json:"gravatar_url,omitempty"` + Position *string `json:"position,omitempty"` + InviteToken *string `json:"invite_token,omitempty"` + SSOInvite *bool `json:"sso_invite,omitempty"` + SSOEnabled *bool `json:"sso_enabled,omitempty"` + AdminForcedPasswordReset *bool `json:"admin_forced_password_reset,omitempty"` + Teams *[]UserTeam `json:"teams,omitempty"` } // User creates a user from payload. @@ -126,6 +139,7 @@ func (p UserPayload) User(keySize, cost int) (*User, error) { Email: *p.Email, Admin: falseIfNil(p.Admin), Enabled: true, + Teams: []UserTeam{}, } if err := user.SetPassword(*p.Password, keySize, cost); err != nil { return nil, err @@ -147,6 +161,9 @@ func (p UserPayload) User(keySize, cost int) (*User, error) { if p.AdminForcedPasswordReset != nil { user.AdminForcedPasswordReset = *p.AdminForcedPasswordReset } + if p.Teams != nil { + user.Teams = *p.Teams + } return user, nil } diff --git a/server/mock/datastore.go b/server/mock/datastore.go index bbd6de854c..dee61d4afd 100644 --- a/server/mock/datastore.go +++ b/server/mock/datastore.go @@ -21,6 +21,7 @@ var _ kolide.Datastore = (*Store)(nil) type Store struct { kolide.PasswordResetStore + kolide.TeamStore TargetStore SessionStore CampaignStore diff --git a/server/service/endpoint_teams.go b/server/service/endpoint_teams.go new file mode 100644 index 0000000000..07a7088e37 --- /dev/null +++ b/server/service/endpoint_teams.go @@ -0,0 +1,64 @@ +package service + +import ( + "context" + + "github.com/fleetdm/fleet/server/kolide" + "github.com/go-kit/kit/endpoint" +) + +//////////////////////////////////////////////////////////////////////////////// +// Create Team +//////////////////////////////////////////////////////////////////////////////// + +type createTeamRequest struct { + payload kolide.TeamPayload +} + +type createTeamResponse struct { + Team *kolide.Team `json:"team,omitempty"` + Err error `json:"error,omitempty"` +} + +func (r createTeamResponse) error() error { return r.Err } + +func makeCreateTeamEndpoint(svc kolide.Service) endpoint.Endpoint { + return func(ctx context.Context, request interface{}) (interface{}, error) { + req := request.(createTeamRequest) + + team, err := svc.NewTeam(ctx, req.payload) + if err != nil { + return createTeamResponse{Err: err}, nil + } + + return createTeamResponse{Team: team}, nil + } +} + +//////////////////////////////////////////////////////////////////////////////// +// Modify Team +//////////////////////////////////////////////////////////////////////////////// + +type modifyTeamRequest struct { + ID uint + payload kolide.TeamPayload +} + +type modifyTeamResponse struct { + Team *kolide.Team `json:"team,omitempty"` + Err error `json:"error,omitempty"` +} + +func (r modifyTeamResponse) error() error { return r.Err } + +func makeModifyTeamEndpoint(svc kolide.Service) endpoint.Endpoint { + return func(ctx context.Context, request interface{}) (interface{}, error) { + req := request.(modifyTeamRequest) + team, err := svc.ModifyTeam(ctx, req.ID, req.payload) + if err != nil { + return modifyTeamResponse{Err: err}, nil + } + + return modifyTeamResponse{Team: team}, err + } +} diff --git a/server/service/handler.go b/server/service/handler.go index 1df123eb6e..42090231f2 100644 --- a/server/service/handler.go +++ b/server/service/handler.go @@ -110,6 +110,8 @@ type KolideEndpoints struct { GetCarve endpoint.Endpoint GetCarveBlock endpoint.Endpoint Version endpoint.Endpoint + CreateTeam endpoint.Endpoint + ModifyTeam endpoint.Endpoint } // MakeKolideServerEndpoints creates the Kolide API endpoints. @@ -212,6 +214,8 @@ func MakeKolideServerEndpoints(svc kolide.Service, jwtKey, urlPrefix string, lim GetCarve: authenticatedUser(jwtKey, svc, makeGetCarveEndpoint(svc)), GetCarveBlock: authenticatedUser(jwtKey, svc, makeGetCarveBlockEndpoint(svc)), Version: authenticatedUser(jwtKey, svc, makeVersionEndpoint(svc)), + CreateTeam: authenticatedUser(jwtKey, svc, makeCreateTeamEndpoint(svc)), + ModifyTeam: authenticatedUser(jwtKey, svc, makeModifyTeamEndpoint(svc)), // Authenticated status endpoints StatusResultStore: authenticatedUser(jwtKey, svc, makeStatusResultStoreEndpoint(svc)), @@ -321,6 +325,8 @@ type kolideHandlers struct { GetCarve http.Handler GetCarveBlock http.Handler Version http.Handler + CreateTeam http.Handler + ModifyTeam http.Handler } func makeKolideKitHandlers(e KolideEndpoints, opts []kithttp.ServerOption) *kolideHandlers { @@ -417,6 +423,8 @@ func makeKolideKitHandlers(e KolideEndpoints, opts []kithttp.ServerOption) *koli GetCarve: newServer(e.GetCarve, decodeGetCarveRequest), GetCarveBlock: newServer(e.GetCarveBlock, decodeGetCarveBlockRequest), Version: newServer(e.Version, decodeNoParamsRequest), + CreateTeam: newServer(e.CreateTeam, decodeCreateTeamRequest), + ModifyTeam: newServer(e.ModifyTeam, decodeModifyTeamRequest), } } @@ -629,6 +637,9 @@ func attachKolideAPIRoutes(r *mux.Router, h *kolideHandlers) { r.Handle("/api/v1/fleet/carves/{id}", h.GetCarve).Methods("GET").Name("get_carve") r.Handle("/api/v1/fleet/carves/{id}/block/{block_id}", h.GetCarveBlock).Methods("GET").Name("get_carve_block") + r.Handle("/api/v1/fleet/teams", h.CreateTeam).Methods("POST").Name("create_team") + r.Handle("/api/v1/fleet/teams/{id}", h.ModifyTeam).Methods("PATCH").Name("modify_team") + r.Handle("/api/v1/osquery/enroll", h.EnrollAgent).Methods("POST").Name("enroll_agent") r.Handle("/api/v1/osquery/config", h.GetClientConfig).Methods("POST").Name("get_client_config") r.Handle("/api/v1/osquery/distributed/read", h.GetDistributedQueries).Methods("POST").Name("get_distributed_queries") diff --git a/server/service/service_teams.go b/server/service/service_teams.go new file mode 100644 index 0000000000..d8ea004997 --- /dev/null +++ b/server/service/service_teams.go @@ -0,0 +1,47 @@ +package service + +import ( + "context" + + "github.com/fleetdm/fleet/server/kolide" +) + +func (svc service) NewTeam(ctx context.Context, p kolide.TeamPayload) (*kolide.Team, error) { + team := &kolide.Team{} + + if p.Name == nil { + return nil, newInvalidArgumentError("name", "missing required argument") + } + if *p.Name == "" { + return nil, newInvalidArgumentError("name", "may not be empty") + } + team.Name = *p.Name + + if p.Description != nil { + team.Description = *p.Description + } + + team, err := svc.ds.NewTeam(team) + if err != nil { + return nil, err + } + return team, nil +} + +func (svc service) ModifyTeam(ctx context.Context, id uint, payload kolide.TeamPayload) (*kolide.Team, error) { + team, err := svc.ds.Team(id) + if err != nil { + return nil, err + } + if payload.Name != nil { + if *payload.Name == "" { + return nil, newInvalidArgumentError("name", "may not be empty") + } + team.Name = *payload.Name + } + if payload.Description != nil { + team.Description = *payload.Description + } + + return svc.ds.SaveTeam(team) +} diff --git a/server/service/service_users.go b/server/service/service_users.go index 81c6b49c31..67532a418c 100644 --- a/server/service/service_users.go +++ b/server/service/service_users.go @@ -120,6 +120,10 @@ func (svc service) ModifyUser(ctx context.Context, userID uint, p kolide.UserPay user.SSOEnabled = *p.SSOEnabled } + if p.Teams != nil { + user.Teams = *p.Teams + } + err = svc.saveUser(user) if err != nil { return nil, err diff --git a/server/service/transport_teams.go b/server/service/transport_teams.go new file mode 100644 index 0000000000..72714ef48c --- /dev/null +++ b/server/service/transport_teams.go @@ -0,0 +1,29 @@ +package service + +import ( + "context" + "encoding/json" + "net/http" +) + +func decodeCreateTeamRequest(ctx context.Context, r *http.Request) (interface{}, error) { + var req createTeamRequest + if err := json.NewDecoder(r.Body).Decode(&req.payload); err != nil { + return nil, err + } + return req, nil +} + +func decodeModifyTeamRequest(ctx context.Context, r *http.Request) (interface{}, error) { + id, err := idFromRequest(r, "id") + if err != nil { + return nil, err + } + var resp modifyTeamRequest + err = json.NewDecoder(r.Body).Decode(&resp.payload) + if err != nil { + return nil, err + } + resp.ID = id + return resp, nil +}