configstore: implement organization members

This commit is contained in:
Simone Gotti 2019-05-03 17:40:07 +02:00
parent a269347c9d
commit 81d656b7a3
10 changed files with 433 additions and 17 deletions

View File

@ -390,6 +390,12 @@ func (c *Client) DeleteUserToken(ctx context.Context, userRef, tokenName string)
return c.getResponse(ctx, "DELETE", fmt.Sprintf("/users/%s/tokens/%s", userRef, tokenName), nil, jsonContent, nil) return c.getResponse(ctx, "DELETE", fmt.Sprintf("/users/%s/tokens/%s", userRef, tokenName), nil, jsonContent, nil)
} }
func (c *Client) GetUserOrgs(ctx context.Context, userRef string) ([]*UserOrgsResponse, *http.Response, error) {
userOrgs := []*UserOrgsResponse{}
resp, err := c.getParsedResponse(ctx, "GET", fmt.Sprintf("/users/%s/orgs", userRef), nil, jsonContent, nil, &userOrgs)
return userOrgs, resp, err
}
func (c *Client) GetRemoteSource(ctx context.Context, rsRef string) (*types.RemoteSource, *http.Response, error) { func (c *Client) GetRemoteSource(ctx context.Context, rsRef string) (*types.RemoteSource, *http.Response, error) {
rs := new(types.RemoteSource) rs := new(types.RemoteSource)
resp, err := c.getParsedResponse(ctx, "GET", fmt.Sprintf("/remotesources/%s", rsRef), nil, jsonContent, nil, rs) resp, err := c.getParsedResponse(ctx, "GET", fmt.Sprintf("/remotesources/%s", rsRef), nil, jsonContent, nil, rs)

View File

@ -496,8 +496,51 @@ func (h *DeleteUserTokenHandler) ServeHTTP(w http.ResponseWriter, r *http.Reques
err := h.ch.DeleteUserToken(ctx, userRef, tokenName) err := h.ch.DeleteUserToken(ctx, userRef, tokenName)
if httpError(w, err) { if httpError(w, err) {
h.log.Errorf("err: %+v", err) h.log.Errorf("err: %+v", err)
return
} }
if err := httpResponse(w, http.StatusNoContent, nil); err != nil { if err := httpResponse(w, http.StatusNoContent, nil); err != nil {
h.log.Errorf("err: %+v", err) h.log.Errorf("err: %+v", err)
} }
} }
type UserOrgsResponse struct {
Organization *types.Organization
Role types.MemberRole
}
func userOrgsResponse(userOrg *command.UserOrgsResponse) *UserOrgsResponse {
return &UserOrgsResponse{
Organization: userOrg.Organization,
Role: userOrg.Role,
}
}
type UserOrgsHandler struct {
log *zap.SugaredLogger
ch *command.CommandHandler
}
func NewUserOrgsHandler(logger *zap.Logger, ch *command.CommandHandler) *UserOrgsHandler {
return &UserOrgsHandler{log: logger.Sugar(), ch: ch}
}
func (h *UserOrgsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
vars := mux.Vars(r)
userRef := vars["userref"]
userOrgs, err := h.ch.GetUserOrgs(ctx, userRef)
if httpError(w, err) {
h.log.Errorf("err: %+v", err)
return
}
res := make([]*UserOrgsResponse, len(userOrgs))
for i, userOrg := range userOrgs {
res[i] = userOrgsResponse(userOrg)
}
if err := httpResponse(w, http.StatusOK, res); err != nil {
h.log.Errorf("err: %+v", err)
}
}

View File

@ -73,12 +73,40 @@ func (s *CommandHandler) CreateOrg(ctx context.Context, org *types.Organization)
return nil, err return nil, err
} }
actions := []*datamanager.Action{}
org.ID = uuid.NewV4().String() org.ID = uuid.NewV4().String()
org.CreatedAt = time.Now() org.CreatedAt = time.Now()
orgj, err := json.Marshal(org) orgj, err := json.Marshal(org)
if err != nil { if err != nil {
return nil, errors.Wrapf(err, "failed to marshal org") return nil, errors.Wrapf(err, "failed to marshal org")
} }
actions = append(actions, &datamanager.Action{
ActionType: datamanager.ActionTypePut,
DataType: string(types.ConfigTypeOrg),
ID: org.ID,
Data: orgj,
})
if org.CreatorUserID != "" {
// add the creator as org member with role owner
orgmember := &types.OrganizationMember{
ID: uuid.NewV4().String(),
OrganizationID: org.ID,
UserID: org.CreatorUserID,
MemberRole: types.MemberRoleOwner,
}
orgmemberj, err := json.Marshal(orgmember)
if err != nil {
return nil, errors.Wrapf(err, "failed to marshal project group")
}
actions = append(actions, &datamanager.Action{
ActionType: datamanager.ActionTypePut,
DataType: string(types.ConfigTypeOrgMember),
ID: orgmember.ID,
Data: orgmemberj,
})
}
pg := &types.ProjectGroup{ pg := &types.ProjectGroup{
ID: uuid.NewV4().String(), ID: uuid.NewV4().String(),
@ -91,20 +119,12 @@ func (s *CommandHandler) CreateOrg(ctx context.Context, org *types.Organization)
if err != nil { if err != nil {
return nil, errors.Wrapf(err, "failed to marshal project group") return nil, errors.Wrapf(err, "failed to marshal project group")
} }
actions := []*datamanager.Action{ actions = append(actions, &datamanager.Action{
{
ActionType: datamanager.ActionTypePut,
DataType: string(types.ConfigTypeOrg),
ID: org.ID,
Data: orgj,
},
{
ActionType: datamanager.ActionTypePut, ActionType: datamanager.ActionTypePut,
DataType: string(types.ConfigTypeProjectGroup), DataType: string(types.ConfigTypeProjectGroup),
ID: pg.ID, ID: pg.ID,
Data: pgj, Data: pgj,
}, })
}
_, err = s.dm.WriteWal(ctx, actions, cgt) _, err = s.dm.WriteWal(ctx, actions, cgt)
return org, err return org, err
@ -115,7 +135,6 @@ func (s *CommandHandler) DeleteOrg(ctx context.Context, orgRef string) error {
var projects []*types.Project var projects []*types.Project
var cgt *datamanager.ChangeGroupsUpdateToken var cgt *datamanager.ChangeGroupsUpdateToken
// must do all the checks in a single transaction to avoid concurrent changes // must do all the checks in a single transaction to avoid concurrent changes
err := s.readDB.Do(func(tx *db.Tx) error { err := s.readDB.Do(func(tx *db.Tx) error {
var err error var err error

View File

@ -21,6 +21,7 @@ import (
"github.com/sorintlab/agola/internal/datamanager" "github.com/sorintlab/agola/internal/datamanager"
"github.com/sorintlab/agola/internal/db" "github.com/sorintlab/agola/internal/db"
"github.com/sorintlab/agola/internal/services/configstore/readdb"
"github.com/sorintlab/agola/internal/services/types" "github.com/sorintlab/agola/internal/services/types"
"github.com/sorintlab/agola/internal/util" "github.com/sorintlab/agola/internal/util"
@ -624,3 +625,42 @@ func (s *CommandHandler) DeleteUserToken(ctx context.Context, userRef, tokenName
_, err = s.dm.WriteWal(ctx, actions, cgt) _, err = s.dm.WriteWal(ctx, actions, cgt)
return err return err
} }
type UserOrgsResponse struct {
Organization *types.Organization
Role types.MemberRole
}
func userOrgsResponse(userOrg *readdb.UserOrg) *UserOrgsResponse {
return &UserOrgsResponse{
Organization: userOrg.Organization,
Role: userOrg.Role,
}
}
func (s *CommandHandler) GetUserOrgs(ctx context.Context, userRef string) ([]*UserOrgsResponse, error) {
var userOrgs []*readdb.UserOrg
err := s.readDB.Do(func(tx *db.Tx) error {
var err error
user, err := s.readDB.GetUser(tx, userRef)
if err != nil {
return err
}
if user == nil {
return util.NewErrNotFound(errors.Errorf("user %q doesn't exist", userRef))
}
userOrgs, err = s.readDB.GetUserOrgs(tx, user.ID)
return err
})
if err != nil {
return nil, err
}
res := make([]*UserOrgsResponse, len(userOrgs))
for i, userOrg := range userOrgs {
res[i] = userOrgsResponse(userOrg)
}
return res, nil
}

View File

@ -78,6 +78,7 @@ func NewConfigStore(ctx context.Context, c *config.ConfigStore) (*ConfigStore, e
DataTypes: []string{ DataTypes: []string{
string(types.ConfigTypeUser), string(types.ConfigTypeUser),
string(types.ConfigTypeOrg), string(types.ConfigTypeOrg),
string(types.ConfigTypeOrgMember),
string(types.ConfigTypeProjectGroup), string(types.ConfigTypeProjectGroup),
string(types.ConfigTypeProject), string(types.ConfigTypeProject),
string(types.ConfigTypeRemoteSource), string(types.ConfigTypeRemoteSource),
@ -154,6 +155,8 @@ func (s *ConfigStore) Run(ctx context.Context) error {
createUserTokenHandler := api.NewCreateUserTokenHandler(logger, s.ch) createUserTokenHandler := api.NewCreateUserTokenHandler(logger, s.ch)
deleteUserTokenHandler := api.NewDeleteUserTokenHandler(logger, s.ch) deleteUserTokenHandler := api.NewDeleteUserTokenHandler(logger, s.ch)
userOrgsHandler := api.NewUserOrgsHandler(logger, s.ch)
orgHandler := api.NewOrgHandler(logger, s.readDB) orgHandler := api.NewOrgHandler(logger, s.readDB)
orgsHandler := api.NewOrgsHandler(logger, s.readDB) orgsHandler := api.NewOrgsHandler(logger, s.readDB)
createOrgHandler := api.NewCreateOrgHandler(logger, s.ch) createOrgHandler := api.NewCreateOrgHandler(logger, s.ch)
@ -202,6 +205,8 @@ func (s *ConfigStore) Run(ctx context.Context) error {
apirouter.Handle("/users/{userref}/tokens", createUserTokenHandler).Methods("POST") apirouter.Handle("/users/{userref}/tokens", createUserTokenHandler).Methods("POST")
apirouter.Handle("/users/{userref}/tokens/{tokenname}", deleteUserTokenHandler).Methods("DELETE") apirouter.Handle("/users/{userref}/tokens/{tokenname}", deleteUserTokenHandler).Methods("DELETE")
apirouter.Handle("/users/{userref}/orgs", userOrgsHandler).Methods("GET")
apirouter.Handle("/orgs/{orgref}", orgHandler).Methods("GET") apirouter.Handle("/orgs/{orgref}", orgHandler).Methods("GET")
apirouter.Handle("/orgs", orgsHandler).Methods("GET") apirouter.Handle("/orgs", orgsHandler).Methods("GET")
apirouter.Handle("/orgs", createOrgHandler).Methods("POST") apirouter.Handle("/orgs", createOrgHandler).Methods("POST")

View File

@ -26,6 +26,7 @@ import (
"testing" "testing"
"time" "time"
"github.com/google/go-cmp/cmp"
"github.com/sorintlab/agola/internal/db" "github.com/sorintlab/agola/internal/db"
"github.com/sorintlab/agola/internal/services/config" "github.com/sorintlab/agola/internal/services/config"
"github.com/sorintlab/agola/internal/services/configstore/command" "github.com/sorintlab/agola/internal/services/configstore/command"
@ -505,3 +506,97 @@ func TestProjectGroupsAndProjects(t *testing.T) {
} }
}) })
} }
func TestOrgMembers(t *testing.T) {
dir, err := ioutil.TempDir("", "agola")
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
defer os.RemoveAll(dir)
ctx := context.Background()
cs, tetcd := setupConfigstore(t, ctx, dir)
defer shutdownEtcd(tetcd)
t.Logf("starting cs")
go func() {
if err := cs.Run(ctx); err != nil {
t.Fatalf("err: %v", err)
}
}()
// TODO(sgotti) change the sleep with a real check that all is ready
time.Sleep(2 * time.Second)
user, err := cs.ch.CreateUser(ctx, &command.CreateUserRequest{UserName: "user01"})
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
org, err := cs.ch.CreateOrg(ctx, &types.Organization{Name: "org01", CreatorUserID: user.ID})
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
// TODO(sgotti) change the sleep with a real check that all is ready
time.Sleep(2 * time.Second)
t.Run("test user org creator is org member with owner role", func(t *testing.T) {
expectedResponse := []*command.UserOrgsResponse{
{
Organization: org,
Role: types.MemberRoleOwner,
},
}
res, err := cs.ch.GetUserOrgs(ctx, user.ID)
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
if diff := cmp.Diff(res, expectedResponse); diff != "" {
t.Error(diff)
}
})
orgs := []*types.Organization{}
for i := 0; i < 10; i++ {
org, err := cs.ch.CreateOrg(ctx, &types.Organization{Name: fmt.Sprintf("org%d", i), CreatorUserID: user.ID})
if err != nil {
t.Fatalf("err: %v", err)
}
orgs = append(orgs, org)
time.Sleep(200 * time.Millisecond)
}
for i := 0; i < 5; i++ {
if err := cs.ch.DeleteOrg(ctx, fmt.Sprintf("org%d", i)); err != nil {
t.Fatalf("err: %v", err)
}
}
// delete some org and check that if also orgmembers aren't yet cleaned only the existing orgs are reported
t.Run("test only existing orgs are reported", func(t *testing.T) {
expectedResponse := []*command.UserOrgsResponse{
{
Organization: org,
Role: types.MemberRoleOwner,
},
}
for i := 5; i < 10; i++ {
expectedResponse = append(expectedResponse, &command.UserOrgsResponse{
Organization: orgs[i],
Role: types.MemberRoleOwner,
})
}
res, err := cs.ch.GetUserOrgs(ctx, user.ID)
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
if diff := cmp.Diff(res, expectedResponse); diff != "" {
t.Error(diff)
}
})
// TODO(sgotti) change the sleep with a real check that user is in readdb
time.Sleep(2 * time.Second)
}

View File

@ -38,6 +38,10 @@ var Stmts = []string{
"create table org (id uuid, name varchar, data bytea, PRIMARY KEY (id))", "create table org (id uuid, name varchar, data bytea, PRIMARY KEY (id))",
"create index org_name on org(name)", "create index org_name on org(name)",
"create table orgmember (id uuid, orgid uuid, userid uuid, role varchar, data bytea, PRIMARY KEY (id))",
"create index orgmember_role on orgmember(role)",
"create index orgmember_orgid_userid on orgmember(orgid, userid)",
"create table remotesource (id uuid, name varchar, data bytea, PRIMARY KEY (id))", "create table remotesource (id uuid, name varchar, data bytea, PRIMARY KEY (id))",
"create table linkedaccount_user (id uuid, remotesourceid uuid, userid uuid, remoteuserid uuid, PRIMARY KEY (id), FOREIGN KEY(userid) REFERENCES user(id))", "create table linkedaccount_user (id uuid, remotesourceid uuid, userid uuid, remoteuserid uuid, PRIMARY KEY (id), FOREIGN KEY(userid) REFERENCES user(id))",

View File

@ -30,6 +30,9 @@ import (
var ( var (
orgSelect = sb.Select("org.id", "org.data").From("org") orgSelect = sb.Select("org.id", "org.data").From("org")
orgInsert = sb.Insert("org").Columns("id", "name", "data") orgInsert = sb.Insert("org").Columns("id", "name", "data")
orgmemberSelect = sb.Select("orgmember.id", "orgmember.data").From("orgmember")
orgmemberInsert = sb.Insert("orgmember").Columns("id", "orgid", "userid", "role", "data")
) )
func (r *ReadDB) insertOrg(tx *db.Tx, data []byte) error { func (r *ReadDB) insertOrg(tx *db.Tx, data []byte) error {
@ -187,12 +190,12 @@ func scanOrgs(rows *sql.Rows) ([]*types.Organization, []string, error) {
orgs := []*types.Organization{} orgs := []*types.Organization{}
ids := []string{} ids := []string{}
for rows.Next() { for rows.Next() {
p, id, err := scanOrg(rows) org, id, err := scanOrg(rows)
if err != nil { if err != nil {
rows.Close() rows.Close()
return nil, nil, err return nil, nil, err
} }
orgs = append(orgs, p) orgs = append(orgs, org)
ids = append(ids, id) ids = append(ids, id)
} }
if err := rows.Err(); err != nil { if err := rows.Err(); err != nil {
@ -200,3 +203,176 @@ func scanOrgs(rows *sql.Rows) ([]*types.Organization, []string, error) {
} }
return orgs, ids, nil return orgs, ids, nil
} }
func (r *ReadDB) insertOrgMember(tx *db.Tx, data []byte) error {
orgmember := types.OrganizationMember{}
if err := json.Unmarshal(data, &orgmember); err != nil {
return errors.Wrap(err, "failed to unmarshal orgmember")
}
r.log.Infof("inserting orgmember: %s", util.Dump(orgmember))
// poor man insert or update...
if err := r.deleteOrgMember(tx, orgmember.ID); err != nil {
return err
}
q, args, err := orgmemberInsert.Values(orgmember.ID, orgmember.OrganizationID, orgmember.UserID, orgmember.MemberRole, data).ToSql()
if err != nil {
return errors.Wrap(err, "failed to build query")
}
if _, err := tx.Exec(q, args...); err != nil {
return errors.Wrap(err, "failed to insert orgmember")
}
return nil
}
func (r *ReadDB) deleteOrgMember(tx *db.Tx, orgmemberID string) error {
if _, err := tx.Exec("delete from orgmember where id = $1", orgmemberID); err != nil {
return errors.Wrap(err, "failed to delete orgmember")
}
return nil
}
func fetchOrgMembers(tx *db.Tx, q string, args ...interface{}) ([]*types.OrganizationMember, []string, error) {
rows, err := tx.Query(q, args...)
if err != nil {
return nil, nil, err
}
defer rows.Close()
return scanOrgMembers(rows)
}
func scanOrgMember(rows *sql.Rows, additionalFields ...interface{}) (*types.OrganizationMember, string, error) {
var id string
var data []byte
if err := rows.Scan(&id, &data); err != nil {
return nil, "", errors.Wrap(err, "failed to scan rows")
}
orgmember := types.OrganizationMember{}
if len(data) > 0 {
if err := json.Unmarshal(data, &orgmember); err != nil {
return nil, "", errors.Wrap(err, "failed to unmarshal org")
}
}
return &orgmember, id, nil
}
func scanOrgMembers(rows *sql.Rows) ([]*types.OrganizationMember, []string, error) {
orgmembers := []*types.OrganizationMember{}
ids := []string{}
for rows.Next() {
orgmember, id, err := scanOrgMember(rows)
if err != nil {
rows.Close()
return nil, nil, err
}
orgmembers = append(orgmembers, orgmember)
ids = append(ids, id)
}
if err := rows.Err(); err != nil {
return nil, nil, err
}
return orgmembers, ids, nil
}
type OrgUser struct {
User *types.User
Role types.MemberRole
}
// TODO(sgotti) implement cursor fetching
func (r *ReadDB) GetOrgUsers(tx *db.Tx, orgID string) ([]*OrgUser, error) {
s := sb.Select("orgmember.data", "user.data").From("orgmember")
s = s.Where(sq.Eq{"orgmember.orgid": orgID})
s = s.Join("user on user.id = orgmember.userid")
s = s.OrderBy("user.name")
q, args, err := s.ToSql()
r.log.Debugf("q: %s, args: %s", q, util.Dump(args))
if err != nil {
return nil, errors.Wrap(err, "failed to build query")
}
rows, err := tx.Query(q, args...)
if err != nil {
return nil, err
}
defer rows.Close()
orgusers := []*OrgUser{}
for rows.Next() {
var orgmember *types.OrganizationMember
var user *types.User
var orgmemberdata []byte
var userdata []byte
if err := rows.Scan(&orgmemberdata, &userdata); err != nil {
return nil, errors.Wrap(err, "failed to scan rows")
}
if err := json.Unmarshal(orgmemberdata, &orgmember); err != nil {
return nil, errors.Wrap(err, "failed to unmarshal orgmember")
}
if err := json.Unmarshal(userdata, &user); err != nil {
return nil, errors.Wrap(err, "failed to unmarshal org")
}
orgusers = append(orgusers, &OrgUser{
User: user,
Role: orgmember.MemberRole,
})
}
if err := rows.Err(); err != nil {
return nil, err
}
return orgusers, nil
}
type UserOrg struct {
Organization *types.Organization
Role types.MemberRole
}
// TODO(sgotti) implement cursor fetching
func (r *ReadDB) GetUserOrgs(tx *db.Tx, userID string) ([]*UserOrg, error) {
s := sb.Select("orgmember.data", "org.data").From("orgmember")
s = s.Where(sq.Eq{"orgmember.userid": userID})
s = s.Join("org on org.id = orgmember.orgid")
s = s.OrderBy("org.name")
q, args, err := s.ToSql()
r.log.Debugf("q: %s, args: %s", q, util.Dump(args))
if err != nil {
return nil, errors.Wrap(err, "failed to build query")
}
rows, err := tx.Query(q, args...)
if err != nil {
return nil, err
}
defer rows.Close()
userorgs := []*UserOrg{}
for rows.Next() {
var orgmember *types.OrganizationMember
var org *types.Organization
var orgmemberdata []byte
var orgdata []byte
if err := rows.Scan(&orgmemberdata, &orgdata); err != nil {
return nil, errors.Wrap(err, "failed to scan rows")
}
if err := json.Unmarshal(orgmemberdata, &orgmember); err != nil {
return nil, errors.Wrap(err, "failed to unmarshal orgmember")
}
if err := json.Unmarshal(orgdata, &org); err != nil {
return nil, errors.Wrap(err, "failed to unmarshal org")
}
userorgs = append(userorgs, &UserOrg{
Organization: org,
Role: orgmember.MemberRole,
})
}
if err := rows.Err(); err != nil {
return nil, err
}
return userorgs, nil
}

View File

@ -587,6 +587,10 @@ func (r *ReadDB) applyAction(tx *db.Tx, action *datamanager.Action) error {
if err := r.insertOrg(tx, action.Data); err != nil { if err := r.insertOrg(tx, action.Data); err != nil {
return err return err
} }
case types.ConfigTypeOrgMember:
if err := r.insertOrgMember(tx, action.Data); err != nil {
return err
}
case types.ConfigTypeProjectGroup: case types.ConfigTypeProjectGroup:
if err := r.insertProjectGroup(tx, action.Data); err != nil { if err := r.insertProjectGroup(tx, action.Data); err != nil {
return err return err
@ -621,6 +625,11 @@ func (r *ReadDB) applyAction(tx *db.Tx, action *datamanager.Action) error {
if err := r.deleteOrg(tx, action.ID); err != nil { if err := r.deleteOrg(tx, action.ID); err != nil {
return err return err
} }
case types.ConfigTypeOrgMember:
r.log.Debugf("deleting orgmember with id: %s", action.ID)
if err := r.deleteOrgMember(tx, action.ID); err != nil {
return err
}
case types.ConfigTypeProjectGroup: case types.ConfigTypeProjectGroup:
r.log.Debugf("deleting project group with id: %s", action.ID) r.log.Debugf("deleting project group with id: %s", action.ID)
if err := r.deleteProjectGroup(tx, action.ID); err != nil { if err := r.deleteProjectGroup(tx, action.ID); err != nil {

View File

@ -27,6 +27,7 @@ type ConfigType string
const ( const (
ConfigTypeUser ConfigType = "user" ConfigTypeUser ConfigType = "user"
ConfigTypeOrg ConfigType = "org" ConfigTypeOrg ConfigType = "org"
ConfigTypeOrgMember ConfigType = "orgmember"
ConfigTypeProjectGroup ConfigType = "projectgroup" ConfigTypeProjectGroup ConfigType = "projectgroup"
ConfigTypeProject ConfigType = "project" ConfigTypeProject ConfigType = "project"
ConfigTypeRemoteSource ConfigType = "remotesource" ConfigTypeRemoteSource ConfigType = "remotesource"
@ -51,6 +52,13 @@ func IsValidVisibility(v Visibility) bool {
return true return true
} }
type MemberRole string
const (
MemberRoleOwner MemberRole = "owner"
MemberRoleMember MemberRole = "member"
)
type Parent struct { type Parent struct {
Type ConfigType `json:"type,omitempty"` Type ConfigType `json:"type,omitempty"`
ID string `json:"id,omitempty"` ID string `json:"id,omitempty"`
@ -91,6 +99,17 @@ type Organization struct {
CreatedAt time.Time `json:"created_at,omitempty"` CreatedAt time.Time `json:"created_at,omitempty"`
} }
type OrganizationMember struct {
Version string `json:"version,omitempty"`
ID string `json:"id,omitempty"`
OrganizationID string `json:"organization_id,omitempty"`
UserID string `json:"user_id,omitempty"`
MemberRole MemberRole `json:"member_role,omitempty"`
}
type ProjectGroup struct { type ProjectGroup struct {
Version string `json:"version,omitempty"` Version string `json:"version,omitempty"`