diff --git a/frontend/kolide/entities/invites.tests.js b/frontend/kolide/entities/invites.tests.js index 32c6c08582..e92d6c86c2 100644 --- a/frontend/kolide/entities/invites.tests.js +++ b/frontend/kolide/entities/invites.tests.js @@ -8,7 +8,6 @@ const { invites: inviteMocks } = mocks; describe('Kolide - API client (invites)', () => { afterEach(() => { - nock.cleanAll(); Kolide.setBearerToken(null); }); diff --git a/server/datastore/datastore_invites_test.go b/server/datastore/datastore_invites_test.go index e3f37fa7c6..2f2826549b 100644 --- a/server/datastore/datastore_invites_test.go +++ b/server/datastore/datastore_invites_test.go @@ -8,14 +8,24 @@ import ( "github.com/fleetdm/fleet/server/kolide" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "gopkg.in/guregu/null.v3" ) func testCreateInvite(t *testing.T, ds kolide.Datastore) { + for i := 0; i < 3; i++ { + _, err := ds.NewTeam(&kolide.Team{Name: fmt.Sprintf("%d", i)}) + require.NoError(t, err) + } + invite := &kolide.Invite{ Email: "user@foo.com", Name: "user", Token: "some_user", + Teams: []kolide.UserTeam{ + {Role: "observer", Team: kolide.Team{ID: 1}}, + {Role: "maintainer", Team: kolide.Team{ID: 3}}, + }, } invite, err := ds.NewInvite(invite) @@ -25,16 +35,17 @@ func testCreateInvite(t *testing.T, ds kolide.Datastore) { require.Nil(t, err) assert.Equal(t, invite.ID, verify.ID) assert.Equal(t, invite.Email, verify.Email) + assert.Len(t, invite.Teams, 2) } func setupTestInvites(t *testing.T, ds kolide.Datastore) { - var err error admin := &kolide.Invite{ - Email: "admin@foo.com", - Admin: true, - Name: "Xadmin", - Token: "admin", + Email: "admin@foo.com", + Admin: true, + Name: "Xadmin", + Token: "admin", + GlobalRole: null.StringFrom("admin"), } admin, err = ds.NewInvite(admin) @@ -42,11 +53,12 @@ func setupTestInvites(t *testing.T, ds kolide.Datastore) { for user := 0; user < 23; user++ { i := kolide.Invite{ - InvitedBy: admin.ID, - Email: fmt.Sprintf("user%d@foo.com", user), - Admin: false, - Name: fmt.Sprintf("User%02d", user), - Token: fmt.Sprintf("usertoken%d", user), + InvitedBy: admin.ID, + Email: fmt.Sprintf("user%d@foo.com", user), + Admin: false, + Name: fmt.Sprintf("User%02d", user), + Token: fmt.Sprintf("usertoken%d", user), + GlobalRole: null.StringFrom("observer"), } _, err := ds.NewInvite(&i) @@ -56,12 +68,6 @@ func setupTestInvites(t *testing.T, ds kolide.Datastore) { } func testListInvites(t *testing.T, ds kolide.Datastore) { - // TODO: fix this for inmem - if ds.Name() == "inmem" { - fmt.Println("Busted test skipped for inmem") - return - } - setupTestInvites(t, ds) opt := kolide.ListOptions{ @@ -77,6 +83,7 @@ func testListInvites(t *testing.T, ds kolide.Datastore) { assert.Equal(t, len(result), 10) assert.Equal(t, "User00", result[0].Name) assert.Equal(t, "User09", result[9].Name) + assert.Equal(t, null.StringFrom("observer"), result[9].GlobalRole) opt.Page = 2 opt.OrderDirection = kolide.OrderDescending @@ -105,31 +112,6 @@ func testDeleteInvite(t *testing.T, ds kolide.Datastore) { } -func testSaveInvite(t *testing.T, ds kolide.Datastore) { - setupTestInvites(t, ds) - - invite, err := ds.InviteByEmail("user0@foo.com") - assert.Nil(t, err) - assert.NotNil(t, invite) - - invite, err = ds.Invite(invite.ID) - assert.Nil(t, err) - assert.NotNil(t, invite) - - invite.Name = "Bob" - invite.Admin = true - - err = ds.SaveInvite(invite) - assert.Nil(t, err) - - invite, err = ds.Invite(invite.ID) - assert.Nil(t, err) - assert.NotNil(t, invite) - assert.Equal(t, "Bob", invite.Name) - assert.True(t, invite.Admin) - -} - func testInviteByToken(t *testing.T, ds kolide.Datastore) { setupTestInvites(t, ds) diff --git a/server/datastore/datastore_test.go b/server/datastore/datastore_test.go index 5e6225080e..b5af80c5a0 100644 --- a/server/datastore/datastore_test.go +++ b/server/datastore/datastore_test.go @@ -17,7 +17,6 @@ var testFunctions = [...]func(*testing.T, kolide.Datastore){ testInviteByToken, testListInvites, testDeleteInvite, - testSaveInvite, testDeleteQuery, testDeleteQueries, testSaveQuery, diff --git a/server/datastore/inmem/invites.go b/server/datastore/inmem/invites.go index c08472baac..358e949b24 100644 --- a/server/datastore/inmem/invites.go +++ b/server/datastore/inmem/invites.go @@ -107,19 +107,6 @@ func (d *Datastore) InviteByToken(token string) (*kolide.Invite, error) { WithMessage(fmt.Sprintf("with token %s", token)) } -// SaveInvite saves an invitation in the datastore. -func (d *Datastore) SaveInvite(invite *kolide.Invite) error { - d.mtx.Lock() - defer d.mtx.Unlock() - - if _, ok := d.invites[invite.ID]; !ok { - return notFound("Invite").WithID(invite.ID) - } - - d.invites[invite.ID] = invite - return nil -} - // DeleteInvite deletes an invitation. func (d *Datastore) DeleteInvite(id uint) error { d.mtx.Lock() diff --git a/server/datastore/mysql/invites.go b/server/datastore/mysql/invites.go index fef24567ec..911e89946a 100644 --- a/server/datastore/mysql/invites.go +++ b/server/datastore/mysql/invites.go @@ -3,20 +3,22 @@ package mysql import ( "database/sql" "fmt" + "strings" "github.com/fleetdm/fleet/server/kolide" + "github.com/jmoiron/sqlx" "github.com/pkg/errors" ) // NewInvite generates a new invitation. func (d *Datastore) NewInvite(i *kolide.Invite) (*kolide.Invite, error) { sqlStmt := ` - INSERT INTO invites ( invited_by, email, admin, name, position, token, sso_enabled) - VALUES ( ?, ?, ?, ?, ?, ?, ?) + INSERT INTO invites ( invited_by, email, admin, name, position, token, sso_enabled, global_role ) + VALUES ( ?, ?, ?, ?, ?, ?, ?, ?) ` result, err := d.db.Exec(sqlStmt, i.InvitedBy, i.Email, i.Admin, - i.Name, i.Position, i.Token, i.SSOEnabled) + i.Name, i.Position, i.Token, i.SSOEnabled, i.GlobalRole) if err != nil && isDuplicate(err) { return nil, alreadyExists("Invite", 0) } else if err != nil { @@ -26,15 +28,30 @@ func (d *Datastore) NewInvite(i *kolide.Invite) (*kolide.Invite, error) { id, _ := result.LastInsertId() i.ID = uint(id) + if len(i.Teams) == 0 { + return i, nil + } + + // Bulk insert teams + const valueStr = "(?,?,?)," + var args []interface{} + for _, userTeam := range i.Teams { + args = append(args, i.ID, userTeam.Team.ID, userTeam.Role) + } + sql := "INSERT INTO invite_teams (invite_id, team_id, role) VALUES " + + strings.Repeat(valueStr, len(i.Teams)) + sql = strings.TrimSuffix(sql, ",") + if _, err := d.db.Exec(sql, args...); err != nil { + return nil, errors.Wrap(err, "insert teams") + } + return i, nil } // ListInvites lists all invites in the Fleet database. Supply query options // using the opt parameter. See kolide.ListOptions func (d *Datastore) ListInvites(opt kolide.ListOptions) ([]*kolide.Invite, error) { - invites := []*kolide.Invite{} - query := appendListOptionsToSQL("SELECT * FROM invites", opt) err := d.db.Select(&invites, query) if err == sql.ErrNoRows { @@ -42,6 +59,11 @@ func (d *Datastore) ListInvites(opt kolide.ListOptions) ([]*kolide.Invite, error } else if err != nil { return nil, errors.Wrap(err, "select invite by ID") } + + if err := d.loadTeamsForInvites(invites); err != nil { + return nil, errors.Wrap(err, "load teams") + } + return invites, nil } @@ -54,6 +76,11 @@ func (d *Datastore) Invite(id uint) (*kolide.Invite, error) { } else if err != nil { return nil, errors.Wrap(err, "select invite by ID") } + + if err := d.loadTeamsForInvites([]*kolide.Invite{&invite}); err != nil { + return nil, errors.Wrap(err, "load teams") + } + return &invite, nil } @@ -67,6 +94,11 @@ func (d *Datastore) InviteByEmail(email string) (*kolide.Invite, error) { } else if err != nil { return nil, errors.Wrap(err, "sqlx get invite by email") } + + if err := d.loadTeamsForInvites([]*kolide.Invite{&invite}); err != nil { + return nil, errors.Wrap(err, "load teams") + } + return &invite, nil } @@ -80,34 +112,56 @@ func (d *Datastore) InviteByToken(token string) (*kolide.Invite, error) { } else if err != nil { return nil, errors.Wrap(err, "sqlx get invite by token") } + + if err := d.loadTeamsForInvites([]*kolide.Invite{&invite}); err != nil { + return nil, errors.Wrap(err, "load teams") + } + return &invite, nil } -// SaveInvite modifies existing Invite -func (d *Datastore) SaveInvite(i *kolide.Invite) error { - sql := ` - UPDATE invites SET invited_by = ?, email = ?, admin = ?, - name = ?, position = ?, token = ?, sso_enabled = ? - WHERE id = ? - ` - results, err := d.db.Exec(sql, i.InvitedBy, i.Email, - i.Admin, i.Name, i.Position, i.Token, i.SSOEnabled, i.ID, - ) - if err != nil { - return errors.Wrap(err, "save invite") - } - rowsAffected, err := results.RowsAffected() - if err != nil { - return errors.Wrap(err, "rows affected updating invite") - } - if rowsAffected == 0 { - return notFound("Invite").WithID(i.ID) - } - - return nil - -} - func (d *Datastore) DeleteInvite(id uint) error { return d.deleteEntity("invites", id) } + +func (d *Datastore) loadTeamsForInvites(invites []*kolide.Invite) error { + inviteIDs := make([]uint, 0, len(invites)+1) + // Make sure the slice is never empty for IN by filling a nonexistent ID + inviteIDs = append(inviteIDs, 0) + idToInvite := make(map[uint]*kolide.Invite, len(invites)) + for _, u := range invites { + // 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 + inviteIDs = append(inviteIDs, u.ID) + idToInvite[u.ID] = u + } + + sql := ` + SELECT ut.team_id AS id, ut.invite_id, ut.role, t.name + FROM invite_teams ut INNER JOIN teams t ON ut.team_id = t.id + WHERE ut.invite_id IN (?) + ORDER BY invite_id, team_id + ` + sql, args, err := sqlx.In(sql, inviteIDs) + if err != nil { + return errors.Wrap(err, "sqlx.In loadTeamsForInvites") + } + + var rows []struct { + kolide.UserTeam + InviteID uint `db:"invite_id"` + } + if err := d.db.Select(&rows, sql, args...); err != nil { + return errors.Wrap(err, "get loadTeamsForInvites") + } + + // Map each row to the appropriate invite + for _, r := range rows { + invite := idToInvite[r.InviteID] + invite.Teams = append(invite.Teams, r.UserTeam) + } + + return nil +} diff --git a/server/datastore/mysql/migrations/tables/20210315111056_CreateTeamsTables.go b/server/datastore/mysql/migrations/tables/20210401111056_CreateTeamsTables.go similarity index 100% rename from server/datastore/mysql/migrations/tables/20210315111056_CreateTeamsTables.go rename to server/datastore/mysql/migrations/tables/20210401111056_CreateTeamsTables.go diff --git a/server/datastore/mysql/migrations/tables/20210401164226_AddRolesToInvites.go b/server/datastore/mysql/migrations/tables/20210401164226_AddRolesToInvites.go new file mode 100644 index 0000000000..b6b193f931 --- /dev/null +++ b/server/datastore/mysql/migrations/tables/20210401164226_AddRolesToInvites.go @@ -0,0 +1,37 @@ +package tables + +import ( + "database/sql" + + "github.com/pkg/errors" +) + +func init() { + MigrationClient.AddMigration(Up_20210401164226, Down_20210401164226) +} + +func Up_20210401164226(tx *sql.Tx) error { + // Invites <> Teams mapping + if _, err := tx.Exec(`CREATE TABLE IF NOT EXISTS invite_teams ( + invite_id INT UNSIGNED NOT NULL, + team_id INT UNSIGNED NOT NULL, + role VARCHAR(64) NOT NULL, + PRIMARY KEY (invite_id, team_id), + FOREIGN KEY fk_invite_id (invite_id) REFERENCES invites (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 invite_teams") + } + + if _, err := tx.Exec(`ALTER TABLE invites + ADD global_role VARCHAR(64) DEFAULT NULL + `); err != nil { + return errors.Wrap(err, "alter users") + } + + return nil +} + +func Down_20210401164226(tx *sql.Tx) error { + return nil +} diff --git a/server/kolide/invites.go b/server/kolide/invites.go index 51d9cccb8f..5d0b308963 100644 --- a/server/kolide/invites.go +++ b/server/kolide/invites.go @@ -2,6 +2,8 @@ package kolide import ( "context" + + "gopkg.in/guregu/null.v3" ) // InviteStore contains the methods for @@ -22,9 +24,6 @@ type InviteStore interface { // InviteByToken retrieves and invite using the token string. InviteByToken(token string) (*Invite, error) - // SaveInvite saves an invitation in the datastore. - SaveInvite(i *Invite) error - // DeleteInvite deletes an invitation. DeleteInvite(id uint) error } @@ -53,18 +52,22 @@ type InvitePayload struct { Admin *bool Name *string Position *string - SSOEnabled *bool `json:"sso_enabled"` + SSOEnabled *bool `json:"sso_enabled"` + GlobalRole *string `json:"global_role"` + Teams []UserTeam `json:"teams,omitempty"` } // Invite represents an invitation for a user to join Fleet. type Invite struct { UpdateCreateTimestamps - ID uint `json:"id"` - InvitedBy uint `json:"invited_by" db:"invited_by"` - Email string `json:"email"` - Admin bool `json:"admin"` - Name string `json:"name"` - Position string `json:"position,omitempty"` - Token string `json:"-"` - SSOEnabled bool `json:"sso_enabled" db:"sso_enabled"` + ID uint `json:"id"` + InvitedBy uint `json:"invited_by" db:"invited_by"` + Email string `json:"email"` + Admin bool `json:"admin"` + Name string `json:"name"` + Position string `json:"position,omitempty"` + Token string `json:"-"` + SSOEnabled bool `json:"sso_enabled" db:"sso_enabled"` + GlobalRole null.String `json:"global_role" db:"global_role"` + Teams []UserTeam `json:"teams,omitempty"` } diff --git a/server/kolide/users.go b/server/kolide/users.go index 577b087929..a5d32456fd 100644 --- a/server/kolide/users.go +++ b/server/kolide/users.go @@ -128,6 +128,7 @@ type UserPayload struct { InviteToken *string `json:"invite_token,omitempty"` SSOInvite *bool `json:"sso_invite,omitempty"` SSOEnabled *bool `json:"sso_enabled,omitempty"` + GlobalRole *string `json:"global_role,omitempty"` AdminForcedPasswordReset *bool `json:"admin_forced_password_reset,omitempty"` Teams *[]UserTeam `json:"teams,omitempty"` } diff --git a/server/mock/datastore_invites.go b/server/mock/datastore_invites.go index c4e6bc82af..6722f58d93 100644 --- a/server/mock/datastore_invites.go +++ b/server/mock/datastore_invites.go @@ -16,8 +16,6 @@ type InviteByEmailFunc func(email string) (*kolide.Invite, error) type InviteByTokenFunc func(token string) (*kolide.Invite, error) -type SaveInviteFunc func(i *kolide.Invite) error - type DeleteInviteFunc func(id uint) error type InviteStore struct { @@ -36,9 +34,6 @@ type InviteStore struct { InviteByTokenFunc InviteByTokenFunc InviteByTokenFuncInvoked bool - SaveInviteFunc SaveInviteFunc - SaveInviteFuncInvoked bool - DeleteInviteFunc DeleteInviteFunc DeleteInviteFuncInvoked bool } @@ -68,11 +63,6 @@ func (s *InviteStore) InviteByToken(token string) (*kolide.Invite, error) { return s.InviteByTokenFunc(token) } -func (s *InviteStore) SaveInvite(i *kolide.Invite) error { - s.SaveInviteFuncInvoked = true - return s.SaveInviteFunc(i) -} - func (s *InviteStore) DeleteInvite(id uint) error { s.DeleteInviteFuncInvoked = true return s.DeleteInviteFunc(id) diff --git a/server/service/service_users.go b/server/service/service_users.go index 67532a418c..8f74bc3433 100644 --- a/server/service/service_users.go +++ b/server/service/service_users.go @@ -21,6 +21,10 @@ func (svc service) CreateUserWithInvite(ctx context.Context, p kolide.UserPayloa // set the payload Admin property based on an existing invite. p.Admin = &invite.Admin + if invite.GlobalRole.Valid { + p.GlobalRole = &invite.GlobalRole.String + } + p.Teams = &invite.Teams user, err := svc.newUser(p) if err != nil {