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.
This commit is contained in:
Zach Wasserman 2021-03-17 21:59:00 -07:00
parent 6f381de04e
commit 3286864d9d
17 changed files with 623 additions and 14 deletions

View file

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

View file

@ -94,4 +94,7 @@ var testFunctions = [...]func(*testing.T, kolide.Datastore){
testCarveListCarves,
testCarveCleanupCarves,
testCarveUpdateCarve,
testTeamGetSet,
testUserTeams,
testUserCreateWithTeams,
}

View file

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

View file

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

View file

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

View file

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

View file

@ -16,6 +16,8 @@ type Datastore interface {
ScheduledQueryStore
OsqueryOptionsStore
CarveStore
TeamStore
Name() string
Drop() error
// MigrateTables creates and migrates the table schemas

View file

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

View file

@ -17,4 +17,5 @@ type Service interface {
ScheduledQueryService
StatusService
CarveService
TeamService
}

56
server/kolide/teams.go Normal file
View file

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

View file

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

View file

@ -21,6 +21,7 @@ var _ kolide.Datastore = (*Store)(nil)
type Store struct {
kolide.PasswordResetStore
kolide.TeamStore
TargetStore
SessionStore
CampaignStore

View file

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

View file

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

View file

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

View file

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

View file

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