feat(access): add guest chat ACL (#235)

This commit is contained in:
BBQ
2026-03-14 17:15:41 +08:00
committed by GitHub
parent c8728ffc2c
commit 839e63acda
86 changed files with 6886 additions and 2554 deletions
+9 -204
View File
@@ -38,8 +38,7 @@ var (
// AccessPolicy controls bot access behavior.
type AccessPolicy struct {
AllowPublicMember bool
AllowGuest bool
AllowGuest bool
}
// NewService creates a new bot service.
@@ -87,12 +86,7 @@ func (s *Service) AuthorizeAccess(ctx context.Context, userID, botID string, isA
return bot, nil
}
if bot.Type == BotTypePublic {
if policy.AllowPublicMember {
if _, err := s.GetMember(ctx, botID, userID); err == nil {
return bot, nil
}
}
if policy.AllowGuest && bot.AllowGuest {
if policy.AllowGuest {
return bot, nil
}
}
@@ -209,57 +203,9 @@ func (s *Service) ListByOwner(ctx context.Context, ownerUserID string) ([]Bot, e
return items, nil
}
// ListByMember returns bots where the user is a member.
func (s *Service) ListByMember(ctx context.Context, channelIdentityID string) ([]Bot, error) {
if s.queries == nil {
return nil, errors.New("bot queries not configured")
}
memberUUID, err := db.ParseUUID(channelIdentityID)
if err != nil {
return nil, err
}
rows, err := s.queries.ListBotsByMember(ctx, memberUUID)
if err != nil {
return nil, err
}
items := make([]Bot, 0, len(rows))
for _, row := range rows {
item, err := toBot(asSQLCBot(row))
if err != nil {
return nil, err
}
if err := s.attachCheckSummary(ctx, &item, asSQLCBot(row)); err != nil {
return nil, err
}
items = append(items, item)
}
return items, nil
}
// ListAccessible returns all bots the user can access (owned or member).
// ListAccessible returns all bots owned by the user.
func (s *Service) ListAccessible(ctx context.Context, channelIdentityID string) ([]Bot, error) {
owned, err := s.ListByOwner(ctx, channelIdentityID)
if err != nil {
return nil, err
}
members, err := s.ListByMember(ctx, channelIdentityID)
if err != nil {
return nil, err
}
seen := map[string]Bot{}
for _, item := range owned {
seen[item.ID] = item
}
for _, item := range members {
if _, ok := seen[item.ID]; !ok {
seen[item.ID] = item
}
}
items := make([]Bot, 0, len(seen))
for _, item := range seen {
items = append(items, item)
}
return items, nil
return s.ListByOwner(ctx, channelIdentityID)
}
// Update updates bot profile fields.
@@ -485,118 +431,6 @@ func (s *Service) ensureUserExists(ctx context.Context, userID pgtype.UUID) erro
return nil
}
// UpsertMember creates or updates a bot membership.
func (s *Service) UpsertMember(ctx context.Context, botID string, req UpsertMemberRequest) (BotMember, error) {
if s.queries == nil {
return BotMember{}, errors.New("bot queries not configured")
}
botUUID, err := db.ParseUUID(botID)
if err != nil {
return BotMember{}, err
}
memberUUID, err := db.ParseUUID(req.UserID)
if err != nil {
return BotMember{}, err
}
role, err := normalizeMemberRole(req.Role)
if err != nil {
return BotMember{}, err
}
row, err := s.queries.UpsertBotMember(ctx, sqlc.UpsertBotMemberParams{
BotID: botUUID,
UserID: memberUUID,
Role: role,
})
if err != nil {
return BotMember{}, err
}
return toBotMember(row), nil
}
// ListMembers returns all members of a bot.
func (s *Service) ListMembers(ctx context.Context, botID string) ([]BotMember, error) {
if s.queries == nil {
return nil, errors.New("bot queries not configured")
}
botUUID, err := db.ParseUUID(botID)
if err != nil {
return nil, err
}
rows, err := s.queries.ListBotMembers(ctx, botUUID)
if err != nil {
return nil, err
}
items := make([]BotMember, 0, len(rows))
for _, row := range rows {
items = append(items, toBotMember(row))
}
return items, nil
}
// GetMember returns a specific bot member.
func (s *Service) GetMember(ctx context.Context, botID, channelIdentityID string) (BotMember, error) {
if s.queries == nil {
return BotMember{}, errors.New("bot queries not configured")
}
botUUID, err := db.ParseUUID(botID)
if err != nil {
return BotMember{}, err
}
memberUUID, err := db.ParseUUID(channelIdentityID)
if err != nil {
return BotMember{}, err
}
row, err := s.queries.GetBotMember(ctx, sqlc.GetBotMemberParams{
BotID: botUUID,
UserID: memberUUID,
})
if err != nil {
return BotMember{}, err
}
return toBotMember(row), nil
}
// DeleteMember removes a member from a bot.
func (s *Service) DeleteMember(ctx context.Context, botID, channelIdentityID string) error {
if s.queries == nil {
return errors.New("bot queries not configured")
}
botUUID, err := db.ParseUUID(botID)
if err != nil {
return err
}
memberUUID, err := db.ParseUUID(channelIdentityID)
if err != nil {
return err
}
return s.queries.DeleteBotMember(ctx, sqlc.DeleteBotMemberParams{
BotID: botUUID,
UserID: memberUUID,
})
}
// UpsertMemberSimple creates or updates a bot membership with a direct channel identity ID and role.
// This satisfies the router.BotMemberService interface.
func (s *Service) UpsertMemberSimple(ctx context.Context, botID, channelIdentityID, role string) error {
_, err := s.UpsertMember(ctx, botID, UpsertMemberRequest{
UserID: channelIdentityID,
Role: role,
})
return err
}
// IsMember checks if a user is a member of a bot.
func (s *Service) IsMember(ctx context.Context, botID, channelIdentityID string) (bool, error) {
_, err := s.GetMember(ctx, botID, channelIdentityID)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return false, nil
}
return false, err
}
return true, nil
}
func normalizeBotType(raw string) (string, error) {
normalized := strings.ToLower(strings.TrimSpace(raw))
if normalized == "" {
@@ -610,35 +444,20 @@ func normalizeBotType(raw string) (string, error) {
}
}
func normalizeMemberRole(raw string) (string, error) {
role := strings.ToLower(strings.TrimSpace(raw))
if role == "" {
return MemberRoleMember, nil
}
switch role {
case MemberRoleOwner, MemberRoleAdmin, MemberRoleMember:
return role, nil
default:
return "", fmt.Errorf("invalid member role: %s", raw)
}
}
func asSQLCBot(v any) sqlc.Bot {
switch r := v.(type) {
case sqlc.Bot:
return r
case sqlc.CreateBotRow:
return sqlc.Bot{ID: r.ID, OwnerUserID: r.OwnerUserID, Type: r.Type, DisplayName: r.DisplayName, AvatarUrl: r.AvatarUrl, IsActive: r.IsActive, Status: r.Status, MaxContextLoadTime: r.MaxContextLoadTime, MaxContextTokens: r.MaxContextTokens, MaxInboxItems: r.MaxInboxItems, Language: r.Language, AllowGuest: r.AllowGuest, ChatModelID: r.ChatModelID, SearchProviderID: r.SearchProviderID, MemoryProviderID: r.MemoryProviderID, Metadata: r.Metadata, CreatedAt: r.CreatedAt, UpdatedAt: r.UpdatedAt}
return sqlc.Bot{ID: r.ID, OwnerUserID: r.OwnerUserID, Type: r.Type, DisplayName: r.DisplayName, AvatarUrl: r.AvatarUrl, IsActive: r.IsActive, Status: r.Status, MaxContextLoadTime: r.MaxContextLoadTime, MaxContextTokens: r.MaxContextTokens, MaxInboxItems: r.MaxInboxItems, Language: r.Language, ReasoningEnabled: r.ReasoningEnabled, ReasoningEffort: r.ReasoningEffort, ChatModelID: r.ChatModelID, SearchProviderID: r.SearchProviderID, MemoryProviderID: r.MemoryProviderID, HeartbeatEnabled: r.HeartbeatEnabled, HeartbeatInterval: r.HeartbeatInterval, HeartbeatPrompt: r.HeartbeatPrompt, Metadata: r.Metadata, CreatedAt: r.CreatedAt, UpdatedAt: r.UpdatedAt}
case sqlc.GetBotByIDRow:
return sqlc.Bot{ID: r.ID, OwnerUserID: r.OwnerUserID, Type: r.Type, DisplayName: r.DisplayName, AvatarUrl: r.AvatarUrl, IsActive: r.IsActive, Status: r.Status, MaxContextLoadTime: r.MaxContextLoadTime, MaxContextTokens: r.MaxContextTokens, MaxInboxItems: r.MaxInboxItems, Language: r.Language, AllowGuest: r.AllowGuest, ChatModelID: r.ChatModelID, SearchProviderID: r.SearchProviderID, MemoryProviderID: r.MemoryProviderID, Metadata: r.Metadata, CreatedAt: r.CreatedAt, UpdatedAt: r.UpdatedAt}
return sqlc.Bot{ID: r.ID, OwnerUserID: r.OwnerUserID, Type: r.Type, DisplayName: r.DisplayName, AvatarUrl: r.AvatarUrl, IsActive: r.IsActive, Status: r.Status, MaxContextLoadTime: r.MaxContextLoadTime, MaxContextTokens: r.MaxContextTokens, MaxInboxItems: r.MaxInboxItems, Language: r.Language, ReasoningEnabled: r.ReasoningEnabled, ReasoningEffort: r.ReasoningEffort, ChatModelID: r.ChatModelID, SearchProviderID: r.SearchProviderID, MemoryProviderID: r.MemoryProviderID, HeartbeatEnabled: r.HeartbeatEnabled, HeartbeatInterval: r.HeartbeatInterval, HeartbeatPrompt: r.HeartbeatPrompt, Metadata: r.Metadata, CreatedAt: r.CreatedAt, UpdatedAt: r.UpdatedAt}
case sqlc.ListBotsByOwnerRow:
return sqlc.Bot{ID: r.ID, OwnerUserID: r.OwnerUserID, Type: r.Type, DisplayName: r.DisplayName, AvatarUrl: r.AvatarUrl, IsActive: r.IsActive, Status: r.Status, MaxContextLoadTime: r.MaxContextLoadTime, MaxContextTokens: r.MaxContextTokens, MaxInboxItems: r.MaxInboxItems, Language: r.Language, AllowGuest: r.AllowGuest, ChatModelID: r.ChatModelID, SearchProviderID: r.SearchProviderID, MemoryProviderID: r.MemoryProviderID, Metadata: r.Metadata, CreatedAt: r.CreatedAt, UpdatedAt: r.UpdatedAt}
case sqlc.ListBotsByMemberRow:
return sqlc.Bot{ID: r.ID, OwnerUserID: r.OwnerUserID, Type: r.Type, DisplayName: r.DisplayName, AvatarUrl: r.AvatarUrl, IsActive: r.IsActive, Status: r.Status, MaxContextLoadTime: r.MaxContextLoadTime, MaxContextTokens: r.MaxContextTokens, MaxInboxItems: r.MaxInboxItems, Language: r.Language, AllowGuest: r.AllowGuest, ChatModelID: r.ChatModelID, SearchProviderID: r.SearchProviderID, MemoryProviderID: r.MemoryProviderID, Metadata: r.Metadata, CreatedAt: r.CreatedAt, UpdatedAt: r.UpdatedAt}
return sqlc.Bot{ID: r.ID, OwnerUserID: r.OwnerUserID, Type: r.Type, DisplayName: r.DisplayName, AvatarUrl: r.AvatarUrl, IsActive: r.IsActive, Status: r.Status, MaxContextLoadTime: r.MaxContextLoadTime, MaxContextTokens: r.MaxContextTokens, MaxInboxItems: r.MaxInboxItems, Language: r.Language, ReasoningEnabled: r.ReasoningEnabled, ReasoningEffort: r.ReasoningEffort, ChatModelID: r.ChatModelID, SearchProviderID: r.SearchProviderID, MemoryProviderID: r.MemoryProviderID, HeartbeatEnabled: r.HeartbeatEnabled, HeartbeatInterval: r.HeartbeatInterval, HeartbeatPrompt: r.HeartbeatPrompt, Metadata: r.Metadata, CreatedAt: r.CreatedAt, UpdatedAt: r.UpdatedAt}
case sqlc.UpdateBotProfileRow:
return sqlc.Bot{ID: r.ID, OwnerUserID: r.OwnerUserID, Type: r.Type, DisplayName: r.DisplayName, AvatarUrl: r.AvatarUrl, IsActive: r.IsActive, Status: r.Status, MaxContextLoadTime: r.MaxContextLoadTime, MaxContextTokens: r.MaxContextTokens, MaxInboxItems: r.MaxInboxItems, Language: r.Language, AllowGuest: r.AllowGuest, ChatModelID: r.ChatModelID, SearchProviderID: r.SearchProviderID, MemoryProviderID: r.MemoryProviderID, Metadata: r.Metadata, CreatedAt: r.CreatedAt, UpdatedAt: r.UpdatedAt}
return sqlc.Bot{ID: r.ID, OwnerUserID: r.OwnerUserID, Type: r.Type, DisplayName: r.DisplayName, AvatarUrl: r.AvatarUrl, IsActive: r.IsActive, Status: r.Status, MaxContextLoadTime: r.MaxContextLoadTime, MaxContextTokens: r.MaxContextTokens, MaxInboxItems: r.MaxInboxItems, Language: r.Language, ReasoningEnabled: r.ReasoningEnabled, ReasoningEffort: r.ReasoningEffort, ChatModelID: r.ChatModelID, SearchProviderID: r.SearchProviderID, MemoryProviderID: r.MemoryProviderID, HeartbeatEnabled: r.HeartbeatEnabled, HeartbeatInterval: r.HeartbeatInterval, HeartbeatPrompt: r.HeartbeatPrompt, Metadata: r.Metadata, CreatedAt: r.CreatedAt, UpdatedAt: r.UpdatedAt}
case sqlc.UpdateBotOwnerRow:
return sqlc.Bot{ID: r.ID, OwnerUserID: r.OwnerUserID, Type: r.Type, DisplayName: r.DisplayName, AvatarUrl: r.AvatarUrl, IsActive: r.IsActive, Status: r.Status, MaxContextLoadTime: r.MaxContextLoadTime, MaxContextTokens: r.MaxContextTokens, MaxInboxItems: r.MaxInboxItems, Language: r.Language, AllowGuest: r.AllowGuest, ChatModelID: r.ChatModelID, SearchProviderID: r.SearchProviderID, MemoryProviderID: r.MemoryProviderID, Metadata: r.Metadata, CreatedAt: r.CreatedAt, UpdatedAt: r.UpdatedAt}
return sqlc.Bot{ID: r.ID, OwnerUserID: r.OwnerUserID, Type: r.Type, DisplayName: r.DisplayName, AvatarUrl: r.AvatarUrl, IsActive: r.IsActive, Status: r.Status, MaxContextLoadTime: r.MaxContextLoadTime, MaxContextTokens: r.MaxContextTokens, MaxInboxItems: r.MaxInboxItems, Language: r.Language, ReasoningEnabled: r.ReasoningEnabled, ReasoningEffort: r.ReasoningEffort, ChatModelID: r.ChatModelID, SearchProviderID: r.SearchProviderID, MemoryProviderID: r.MemoryProviderID, HeartbeatEnabled: r.HeartbeatEnabled, HeartbeatInterval: r.HeartbeatInterval, HeartbeatPrompt: r.HeartbeatPrompt, Metadata: r.Metadata, CreatedAt: r.CreatedAt, UpdatedAt: r.UpdatedAt}
default:
return sqlc.Bot{}
}
@@ -672,7 +491,6 @@ func toBot(row sqlc.Bot) (Bot, error) {
DisplayName: displayName,
AvatarURL: avatarURL,
IsActive: row.IsActive,
AllowGuest: row.AllowGuest,
Status: strings.TrimSpace(row.Status),
CheckState: BotCheckStateUnknown,
CheckIssueCount: 0,
@@ -682,19 +500,6 @@ func toBot(row sqlc.Bot) (Bot, error) {
}, nil
}
func toBotMember(row sqlc.BotMember) BotMember {
createdAt := time.Time{}
if row.CreatedAt.Valid {
createdAt = row.CreatedAt.Time
}
return BotMember{
BotID: row.BotID.String(),
UserID: row.UserID.String(),
Role: row.Role,
CreatedAt: createdAt,
}
}
func decodeMetadata(payload []byte) (map[string]any, error) {
if len(payload) == 0 {
return map[string]any{}, nil
+20 -78
View File
@@ -42,13 +42,13 @@ func (d *fakeDBTX) QueryRow(ctx context.Context, sql string, args ...any) pgx.Ro
// makeBotRow creates a fakeRow that populates a sqlc.Bot via Scan.
// Column order: id, owner_user_id, type, display_name, avatar_url, is_active, status,
// max_context_load_time, max_context_tokens, max_inbox_items, language, allow_guest,
// max_context_load_time, max_context_tokens, max_inbox_items, language,
// reasoning_enabled, reasoning_effort, chat_model_id, search_provider_id, memory_provider_id,
// heartbeat_enabled, heartbeat_interval, heartbeat_prompt, metadata, created_at, updated_at.
func makeBotRow(botID, ownerUserID pgtype.UUID, botType string, allowGuest bool) *fakeRow {
return &fakeRow{
scanFunc: func(dest ...any) error {
if len(dest) < 23 {
if len(dest) < 22 {
return pgx.ErrNoRows
}
*dest[0].(*pgtype.UUID) = botID
@@ -62,42 +62,23 @@ func makeBotRow(botID, ownerUserID pgtype.UUID, botType string, allowGuest bool)
*dest[8].(*int32) = 4096 // MaxContextTokens
*dest[9].(*int32) = 10 // MaxInboxItems
*dest[10].(*string) = "en"
*dest[11].(*bool) = allowGuest
*dest[12].(*bool) = false // ReasoningEnabled
*dest[13].(*string) = "medium" // ReasoningEffort
*dest[14].(*pgtype.UUID) = pgtype.UUID{} // ChatModelID
*dest[15].(*pgtype.UUID) = pgtype.UUID{} // SearchProviderID
*dest[16].(*pgtype.UUID) = pgtype.UUID{} // MemoryProviderID
*dest[17].(*bool) = false // HeartbeatEnabled
*dest[18].(*int32) = 30 // HeartbeatInterval
*dest[19].(*string) = "" // HeartbeatPrompt
*dest[20].(*[]byte) = []byte(`{}`)
_ = allowGuest
*dest[11].(*bool) = false // ReasoningEnabled
*dest[12].(*string) = "medium" // ReasoningEffort
*dest[13].(*pgtype.UUID) = pgtype.UUID{} // ChatModelID
*dest[14].(*pgtype.UUID) = pgtype.UUID{} // SearchProviderID
*dest[15].(*pgtype.UUID) = pgtype.UUID{} // MemoryProviderID
*dest[16].(*bool) = false // HeartbeatEnabled
*dest[17].(*int32) = 30 // HeartbeatInterval
*dest[18].(*string) = "" // HeartbeatPrompt
*dest[19].(*[]byte) = []byte(`{}`)
*dest[20].(*pgtype.Timestamptz) = pgtype.Timestamptz{}
*dest[21].(*pgtype.Timestamptz) = pgtype.Timestamptz{}
*dest[22].(*pgtype.Timestamptz) = pgtype.Timestamptz{}
return nil
},
}
}
func makeMemberRow(botID, userID pgtype.UUID) *fakeRow {
return &fakeRow{
scanFunc: func(dest ...any) error {
if len(dest) < 4 {
return pgx.ErrNoRows
}
*dest[0].(*pgtype.UUID) = botID
*dest[1].(*pgtype.UUID) = userID
*dest[2].(*string) = MemberRoleMember
*dest[3].(*pgtype.Timestamptz) = pgtype.Timestamptz{}
return nil
},
}
}
func makeNoRow() *fakeRow {
return &fakeRow{scanFunc: func(_ ...any) error { return pgx.ErrNoRows }}
}
func mustParseUUID(s string) pgtype.UUID {
var u pgtype.UUID
_ = u.Scan(s)
@@ -108,12 +89,9 @@ func TestAuthorizeAccess(t *testing.T) {
ownerUUID := mustParseUUID("00000000-0000-0000-0000-000000000001")
botUUID := mustParseUUID("00000000-0000-0000-0000-000000000002")
strangerUUID := mustParseUUID("00000000-0000-0000-0000-000000000003")
memberUUID := mustParseUUID("00000000-0000-0000-0000-000000000004")
ownerID := ownerUUID.String()
botID := botUUID.String()
strangerID := strangerUUID.String()
memberID := memberUUID.String()
tests := []struct {
name string
@@ -122,7 +100,6 @@ func TestAuthorizeAccess(t *testing.T) {
policy AccessPolicy
botType string
allowGst bool
isMember bool
wantErr bool
wantErrIs error
}{
@@ -146,50 +123,21 @@ func TestAuthorizeAccess(t *testing.T) {
userID: strangerID,
policy: AccessPolicy{AllowGuest: false},
botType: BotTypePublic,
allowGst: true,
wantErr: true,
wantErrIs: ErrBotAccessDenied,
},
{
name: "stranger denied when bot guest disabled",
userID: strangerID,
policy: AccessPolicy{AllowGuest: true},
botType: BotTypePublic,
allowGst: false,
wantErr: true,
wantErrIs: ErrBotAccessDenied,
},
{
name: "stranger allowed when policy and bot both allow guest",
userID: strangerID,
policy: AccessPolicy{AllowGuest: true},
botType: BotTypePublic,
allowGst: true,
wantErr: false,
name: "stranger allowed when policy allows guest",
userID: strangerID,
policy: AccessPolicy{AllowGuest: true},
botType: BotTypePublic,
wantErr: false,
},
{
name: "guest not allowed on personal bot",
userID: strangerID,
policy: AccessPolicy{AllowGuest: true},
botType: BotTypePersonal,
allowGst: true,
wantErr: true,
wantErrIs: ErrBotAccessDenied,
},
{
name: "member allowed with AllowPublicMember policy",
userID: memberID,
policy: AccessPolicy{AllowPublicMember: true},
botType: BotTypePublic,
isMember: true,
wantErr: false,
},
{
name: "non-member denied with AllowPublicMember policy",
userID: strangerID,
policy: AccessPolicy{AllowPublicMember: true},
botType: BotTypePublic,
isMember: false,
wantErr: true,
wantErrIs: ErrBotAccessDenied,
},
@@ -199,14 +147,8 @@ func TestAuthorizeAccess(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
db := &fakeDBTX{
queryRowFunc: func(_ context.Context, _ string, args ...any) pgx.Row {
// Route to bot or member row based on query.
if len(args) == 1 {
return makeBotRow(botUUID, ownerUUID, tt.botType, tt.allowGst)
}
if tt.isMember {
return makeMemberRow(botUUID, memberUUID)
}
return makeNoRow()
_ = args
return makeBotRow(botUUID, ownerUUID, tt.botType, tt.allowGst)
},
}
svc := NewService(nil, sqlc.New(db))
-26
View File
@@ -13,7 +13,6 @@ type Bot struct {
DisplayName string `json:"display_name"`
AvatarURL string `json:"avatar_url,omitempty"`
IsActive bool `json:"is_active"`
AllowGuest bool `json:"allow_guest"`
Status string `json:"status"`
CheckState string `json:"check_state"`
CheckIssueCount int32 `json:"check_issue_count"`
@@ -22,14 +21,6 @@ type Bot struct {
UpdatedAt time.Time `json:"updated_at"`
}
// BotMember represents a bot membership record.
type BotMember struct {
BotID string `json:"bot_id"`
UserID string `json:"user_id"`
Role string `json:"role"`
CreatedAt time.Time `json:"created_at"`
}
// BotCheck represents one resource check row for a bot.
type BotCheck struct {
ID string `json:"id"`
@@ -64,22 +55,11 @@ type TransferBotRequest struct {
OwnerUserID string `json:"owner_user_id"`
}
// UpsertMemberRequest is the input for upserting a bot member.
type UpsertMemberRequest struct {
UserID string `json:"user_id"`
Role string `json:"role,omitempty"`
}
// ListBotsResponse wraps a list of bots.
type ListBotsResponse struct {
Items []Bot `json:"items"`
}
// ListMembersResponse wraps a list of bot members.
type ListMembersResponse struct {
Items []BotMember `json:"items"`
}
// ListChecksResponse wraps a list of bot checks.
type ListChecksResponse struct {
Items []BotCheck `json:"items"`
@@ -130,9 +110,3 @@ const (
BotCheckTypeMCPConnection = "mcp.connection"
BotCheckTypeChannelConn = "channel.connection"
)
const (
MemberRoleOwner = "owner"
MemberRoleAdmin = "admin"
MemberRoleMember = "member"
)