Add role storage to invites APIs (#576)

- Reorder migrations post-rebase
- Fix global_role in user payload
- Add teams/roles to invite entities
- Add teams/roles support to invite datastore methods
- Update tests
- Carry over team information from invite when creating user
This commit is contained in:
Zach Wasserman 2021-04-05 11:15:26 -07:00 committed by GitHub
parent 56d2480389
commit 08fce719e9
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 164 additions and 108 deletions

View file

@ -8,7 +8,6 @@ const { invites: inviteMocks } = mocks;
describe('Kolide - API client (invites)', () => {
afterEach(() => {
nock.cleanAll();
Kolide.setBearerToken(null);
});

View file

@ -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)

View file

@ -17,7 +17,6 @@ var testFunctions = [...]func(*testing.T, kolide.Datastore){
testInviteByToken,
testListInvites,
testDeleteInvite,
testSaveInvite,
testDeleteQuery,
testDeleteQueries,
testSaveQuery,

View file

@ -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()

View file

@ -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
}

View file

@ -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
}

View file

@ -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"`
}

View file

@ -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"`
}

View file

@ -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)

View file

@ -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 {