fix: allow group members to read group information (#14200)

* - allow group members to read basic Group info
- allow group members to see they are part of the group, but not see that information about other members
- add a GetGroupMembersCountByGroupID SQL query, which allows group members to see members count without revealing other information about the members
- add the group_members_expanded db view
- rewrite group member queries to use the group_members_expanded view
- add the RBAC ResourceGroupMember and add it to relevant roles
- rewrite GetGroupMembersByGroupID permission checks
- make the GroupMember type contain all user fields
- fix type issues coming from replacing User with GroupMember in group member queries
- add the MemberTotalCount field to codersdk.Group
- display `group.total_member_count` instead of `group.members.length` on the account page
This commit is contained in:
Hugo Dutka
2024-08-13 09:20:24 -05:00
committed by GitHub
parent 60218c4c78
commit 6f9b1a39f4
38 changed files with 734 additions and 315 deletions
+39 -9
View File
@@ -159,6 +159,35 @@ func ReducedUser(user database.User) codersdk.ReducedUser {
}
}
func UserFromGroupMember(member database.GroupMember) database.User {
return database.User{
ID: member.UserID,
Email: member.UserEmail,
Username: member.UserUsername,
HashedPassword: member.UserHashedPassword,
CreatedAt: member.UserCreatedAt,
UpdatedAt: member.UserUpdatedAt,
Status: member.UserStatus,
RBACRoles: member.UserRbacRoles,
LoginType: member.UserLoginType,
AvatarURL: member.UserAvatarUrl,
Deleted: member.UserDeleted,
LastSeenAt: member.UserLastSeenAt,
QuietHoursSchedule: member.UserQuietHoursSchedule,
ThemePreference: member.UserThemePreference,
Name: member.UserName,
GithubComUserID: member.UserGithubComUserID,
}
}
func ReducedUserFromGroupMember(member database.GroupMember) codersdk.ReducedUser {
return ReducedUser(UserFromGroupMember(member))
}
func ReducedUsersFromGroupMembers(members []database.GroupMember) []codersdk.ReducedUser {
return List(members, ReducedUserFromGroupMember)
}
func ReducedUsers(users []database.User) []codersdk.ReducedUser {
return List(users, ReducedUser)
}
@@ -179,16 +208,17 @@ func Users(users []database.User, organizationIDs map[uuid.UUID][]uuid.UUID) []c
})
}
func Group(group database.Group, members []database.User) codersdk.Group {
func Group(group database.Group, members []database.GroupMember, totalMemberCount int) codersdk.Group {
return codersdk.Group{
ID: group.ID,
Name: group.Name,
DisplayName: group.DisplayName,
OrganizationID: group.OrganizationID,
AvatarURL: group.AvatarURL,
Members: ReducedUsers(members),
QuotaAllowance: int(group.QuotaAllowance),
Source: codersdk.GroupSource(group.Source),
ID: group.ID,
Name: group.Name,
DisplayName: group.DisplayName,
OrganizationID: group.OrganizationID,
AvatarURL: group.AvatarURL,
Members: ReducedUsersFromGroupMembers(members),
TotalMemberCount: totalMemberCount,
QuotaAllowance: int(group.QuotaAllowance),
Source: codersdk.GroupSource(group.Source),
}
}
+12 -4
View File
@@ -1396,11 +1396,19 @@ func (q *querier) GetGroupMembers(ctx context.Context) ([]database.GroupMember,
return q.db.GetGroupMembers(ctx)
}
func (q *querier) GetGroupMembersByGroupID(ctx context.Context, id uuid.UUID) ([]database.User, error) {
if _, err := q.GetGroupByID(ctx, id); err != nil { // AuthZ check
return nil, err
func (q *querier) GetGroupMembersByGroupID(ctx context.Context, id uuid.UUID) ([]database.GroupMember, error) {
return fetchWithPostFilter(q.auth, policy.ActionRead, q.db.GetGroupMembersByGroupID)(ctx, id)
}
func (q *querier) GetGroupMembersCountByGroupID(ctx context.Context, groupID uuid.UUID) (int64, error) {
if _, err := q.GetGroupByID(ctx, groupID); err != nil { // AuthZ check
return 0, err
}
return q.db.GetGroupMembersByGroupID(ctx, id)
memberCount, err := q.db.GetGroupMembersCountByGroupID(ctx, groupID)
if err != nil {
return 0, err
}
return memberCount, nil
}
func (q *querier) GetGroups(ctx context.Context) ([]database.Group, error) {
+17 -7
View File
@@ -305,8 +305,10 @@ func (s *MethodTestSuite) TestGroup() {
}))
s.Run("DeleteGroupMemberFromGroup", s.Subtest(func(db database.Store, check *expects) {
g := dbgen.Group(s.T(), db, database.Group{})
m := dbgen.GroupMember(s.T(), db, database.GroupMember{
u := dbgen.User(s.T(), db, database.User{})
m := dbgen.GroupMember(s.T(), db, database.GroupMemberTable{
GroupID: g.ID,
UserID: u.ID,
})
check.Args(database.DeleteGroupMemberFromGroupParams{
UserID: m.UserID,
@@ -326,11 +328,18 @@ func (s *MethodTestSuite) TestGroup() {
}))
s.Run("GetGroupMembersByGroupID", s.Subtest(func(db database.Store, check *expects) {
g := dbgen.Group(s.T(), db, database.Group{})
_ = dbgen.GroupMember(s.T(), db, database.GroupMember{})
u := dbgen.User(s.T(), db, database.User{})
gm := dbgen.GroupMember(s.T(), db, database.GroupMemberTable{GroupID: g.ID, UserID: u.ID})
check.Args(g.ID).Asserts(gm, policy.ActionRead)
}))
s.Run("GetGroupMembersCountByGroupID", s.Subtest(func(db database.Store, check *expects) {
g := dbgen.Group(s.T(), db, database.Group{})
check.Args(g.ID).Asserts(g, policy.ActionRead)
}))
s.Run("GetGroupMembers", s.Subtest(func(db database.Store, check *expects) {
_ = dbgen.GroupMember(s.T(), db, database.GroupMember{})
g := dbgen.Group(s.T(), db, database.Group{})
u := dbgen.User(s.T(), db, database.User{})
dbgen.GroupMember(s.T(), db, database.GroupMemberTable{GroupID: g.ID, UserID: u.ID})
check.Asserts(rbac.ResourceSystem, policy.ActionRead)
}))
s.Run("GetGroups", s.Subtest(func(db database.Store, check *expects) {
@@ -339,7 +348,8 @@ func (s *MethodTestSuite) TestGroup() {
}))
s.Run("GetGroupsByOrganizationAndUserID", s.Subtest(func(db database.Store, check *expects) {
g := dbgen.Group(s.T(), db, database.Group{})
gm := dbgen.GroupMember(s.T(), db, database.GroupMember{GroupID: g.ID})
u := dbgen.User(s.T(), db, database.User{})
gm := dbgen.GroupMember(s.T(), db, database.GroupMemberTable{GroupID: g.ID, UserID: u.ID})
check.Args(database.GetGroupsByOrganizationAndUserIDParams{
OrganizationID: g.OrganizationID,
UserID: gm.UserID,
@@ -368,7 +378,7 @@ func (s *MethodTestSuite) TestGroup() {
u1 := dbgen.User(s.T(), db, database.User{})
g1 := dbgen.Group(s.T(), db, database.Group{OrganizationID: o.ID})
g2 := dbgen.Group(s.T(), db, database.Group{OrganizationID: o.ID})
_ = dbgen.GroupMember(s.T(), db, database.GroupMember{GroupID: g1.ID, UserID: u1.ID})
_ = dbgen.GroupMember(s.T(), db, database.GroupMemberTable{GroupID: g1.ID, UserID: u1.ID})
check.Args(database.InsertUserGroupsByNameParams{
OrganizationID: o.ID,
UserID: u1.ID,
@@ -380,8 +390,8 @@ func (s *MethodTestSuite) TestGroup() {
u1 := dbgen.User(s.T(), db, database.User{})
g1 := dbgen.Group(s.T(), db, database.Group{OrganizationID: o.ID})
g2 := dbgen.Group(s.T(), db, database.Group{OrganizationID: o.ID})
_ = dbgen.GroupMember(s.T(), db, database.GroupMember{GroupID: g1.ID, UserID: u1.ID})
_ = dbgen.GroupMember(s.T(), db, database.GroupMember{GroupID: g2.ID, UserID: u1.ID})
_ = dbgen.GroupMember(s.T(), db, database.GroupMemberTable{GroupID: g1.ID, UserID: u1.ID})
_ = dbgen.GroupMember(s.T(), db, database.GroupMemberTable{GroupID: g2.ID, UserID: u1.ID})
check.Args(u1.ID).Asserts(rbac.ResourceSystem, policy.ActionUpdate).Returns()
}))
s.Run("UpdateGroupByID", s.Subtest(func(db database.Store, check *expects) {
+6 -10
View File
@@ -115,18 +115,15 @@ func TestGroupsAuth(t *testing.T) {
Name: "GroupMember",
Subject: rbac.Subject{
ID: users[0].ID.String(),
Roles: rbac.Roles(must(rbac.RoleIdentifiers{rbac.ScopedRoleOrgMember(org.ID)}.Expand())),
Roles: rbac.Roles(must(rbac.RoleIdentifiers{rbac.RoleMember(), rbac.ScopedRoleOrgMember(org.ID)}.Expand())),
Groups: []string{
group.Name,
group.ID.String(),
},
Scope: rbac.ExpandableScope(rbac.ScopeAll),
},
// TODO: currently group members cannot see their own groups.
// If this is fixed, these booleans should be flipped to true.
ReadGroup: false,
ReadMembers: false,
// TODO: If fixed, they should only be able to see themselves
// MembersExpected: 1,
ReadGroup: true,
ReadMembers: true,
MembersExpected: 1,
},
{
// Org admin in the incorrect organization
@@ -160,8 +157,7 @@ func TestGroupsAuth(t *testing.T) {
require.NoError(t, err, "member read")
require.Len(t, members, tc.MembersExpected, "member count found does not match")
} else {
require.Error(t, err, "member read")
require.True(t, dbauthz.IsNotAuthorizedError(err), "not authorized error")
require.Len(t, members, 0, "member count is not 0")
}
})
}
+50 -6
View File
@@ -6,6 +6,7 @@ import (
"database/sql"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"net"
"strings"
@@ -374,18 +375,61 @@ func Group(t testing.TB, db database.Store, orig database.Group) database.Group
return group
}
func GroupMember(t testing.TB, db database.Store, orig database.GroupMember) database.GroupMember {
member := database.GroupMember{
UserID: takeFirst(orig.UserID, uuid.New()),
GroupID: takeFirst(orig.GroupID, uuid.New()),
}
// GroupMember requires a user + group to already exist.
// Example for creating a group member for a random group + user.
//
// GroupMember(t, db, database.GroupMemberTable{
// UserID: User(t, db, database.User{}).ID,
// GroupID: Group(t, db, database.Group{
// OrganizationID: must(db.GetDefaultOrganization(genCtx)).ID,
// }).ID,
// })
func GroupMember(t testing.TB, db database.Store, member database.GroupMemberTable) database.GroupMember {
require.NotEqual(t, member.UserID, uuid.Nil, "A user id is required to use 'dbgen.GroupMember', use 'dbgen.User'.")
require.NotEqual(t, member.GroupID, uuid.Nil, "A group id is required to use 'dbgen.GroupMember', use 'dbgen.Group'.")
//nolint:gosimple
err := db.InsertGroupMember(genCtx, database.InsertGroupMemberParams{
UserID: member.UserID,
GroupID: member.GroupID,
})
require.NoError(t, err, "insert group member")
return member
user, err := db.GetUserByID(genCtx, member.UserID)
if errors.Is(err, sql.ErrNoRows) {
require.NoErrorf(t, err, "'dbgen.GroupMember' failed as the user with id %s does not exist. A user is required to use this function, use 'dbgen.User'.", member.UserID)
}
require.NoError(t, err, "get user by id")
group, err := db.GetGroupByID(genCtx, member.GroupID)
if errors.Is(err, sql.ErrNoRows) {
require.NoErrorf(t, err, "'dbgen.GroupMember' failed as the group with id %s does not exist. A group is required to use this function, use 'dbgen.Group'.", member.GroupID)
}
require.NoError(t, err, "get group by id")
groupMember := database.GroupMember{
UserID: user.ID,
UserEmail: user.Email,
UserUsername: user.Username,
UserHashedPassword: user.HashedPassword,
UserCreatedAt: user.CreatedAt,
UserUpdatedAt: user.UpdatedAt,
UserStatus: user.Status,
UserRbacRoles: user.RBACRoles,
UserLoginType: user.LoginType,
UserAvatarUrl: user.AvatarURL,
UserDeleted: user.Deleted,
UserLastSeenAt: user.LastSeenAt,
UserQuietHoursSchedule: user.QuietHoursSchedule,
UserThemePreference: user.ThemePreference,
UserName: user.Name,
UserGithubComUserID: user.GithubComUserID,
OrganizationID: group.OrganizationID,
GroupName: group.Name,
GroupID: group.ID,
}
return groupMember
}
// ProvisionerJob is a bit more involved to get the values such as "completedAt", "startedAt", "cancelledAt" set. ps
+2 -2
View File
@@ -102,8 +102,8 @@ func TestGenerator(t *testing.T) {
db := dbmem.New()
g := dbgen.Group(t, db, database.Group{})
u := dbgen.User(t, db, database.User{})
exp := []database.User{u}
dbgen.GroupMember(t, db, database.GroupMember{GroupID: g.ID, UserID: u.ID})
gm := dbgen.GroupMember(t, db, database.GroupMemberTable{GroupID: g.ID, UserID: u.ID})
exp := []database.GroupMember{gm}
require.Equal(t, exp, must(db.GetGroupMembersByGroupID(context.Background(), g.ID)))
})
+104 -29
View File
@@ -60,7 +60,7 @@ func New() database.Store {
dbcryptKeys: make([]database.DBCryptKey, 0),
externalAuthLinks: make([]database.ExternalAuthLink, 0),
groups: make([]database.Group, 0),
groupMembers: make([]database.GroupMember, 0),
groupMembers: make([]database.GroupMemberTable, 0),
auditLogs: make([]database.AuditLog, 0),
files: make([]database.File, 0),
gitSSHKey: make([]database.GitSSHKey, 0),
@@ -156,7 +156,7 @@ type data struct {
files []database.File
externalAuthLinks []database.ExternalAuthLink
gitSSHKey []database.GitSSHKey
groupMembers []database.GroupMember
groupMembers []database.GroupMemberTable
groups []database.Group
jfrogXRayScans []database.JfrogXrayScan
licenses []database.License
@@ -723,18 +723,68 @@ func (q *FakeQuerier) getOrganizationMemberNoLock(orgID uuid.UUID) []database.Or
return members
}
var errUserDeleted = xerrors.New("user deleted")
// getGroupMemberNoLock fetches a group member by user ID and group ID.
func (q *FakeQuerier) getGroupMemberNoLock(ctx context.Context, userID, groupID uuid.UUID) (database.GroupMember, error) {
groupName := "Everyone"
orgID := groupID
groupIsEveryone := q.isEveryoneGroup(groupID)
if !groupIsEveryone {
group, err := q.getGroupByIDNoLock(ctx, groupID)
if err != nil {
return database.GroupMember{}, err
}
groupName = group.Name
orgID = group.OrganizationID
}
user, err := q.getUserByIDNoLock(userID)
if err != nil {
return database.GroupMember{}, err
}
if user.Deleted {
return database.GroupMember{}, errUserDeleted
}
return database.GroupMember{
UserID: user.ID,
UserEmail: user.Email,
UserUsername: user.Username,
UserHashedPassword: user.HashedPassword,
UserCreatedAt: user.CreatedAt,
UserUpdatedAt: user.UpdatedAt,
UserStatus: user.Status,
UserRbacRoles: user.RBACRoles,
UserLoginType: user.LoginType,
UserAvatarUrl: user.AvatarURL,
UserDeleted: user.Deleted,
UserLastSeenAt: user.LastSeenAt,
UserQuietHoursSchedule: user.QuietHoursSchedule,
UserThemePreference: user.ThemePreference,
UserName: user.Name,
UserGithubComUserID: user.GithubComUserID,
OrganizationID: orgID,
GroupName: groupName,
GroupID: groupID,
}, nil
}
// getEveryoneGroupMembersNoLock fetches all the users in an organization.
func (q *FakeQuerier) getEveryoneGroupMembersNoLock(orgID uuid.UUID) []database.User {
func (q *FakeQuerier) getEveryoneGroupMembersNoLock(ctx context.Context, orgID uuid.UUID) []database.GroupMember {
var (
everyone []database.User
everyone []database.GroupMember
orgMembers = q.getOrganizationMemberNoLock(orgID)
)
for _, member := range orgMembers {
user, err := q.getUserByIDNoLock(member.UserID)
groupMember, err := q.getGroupMemberNoLock(ctx, member.UserID, orgID)
if errors.Is(err, errUserDeleted) {
continue
}
if err != nil {
return nil
}
everyone = append(everyone, user)
everyone = append(everyone, groupMember)
}
return everyone
}
@@ -2486,42 +2536,67 @@ func (q *FakeQuerier) GetGroupByOrgAndName(_ context.Context, arg database.GetGr
return database.Group{}, sql.ErrNoRows
}
func (q *FakeQuerier) GetGroupMembers(_ context.Context) ([]database.GroupMember, error) {
func (q *FakeQuerier) GetGroupMembers(ctx context.Context) ([]database.GroupMember, error) {
q.mutex.RLock()
defer q.mutex.RUnlock()
out := make([]database.GroupMember, len(q.groupMembers))
copy(out, q.groupMembers)
return out, nil
members := make([]database.GroupMemberTable, 0, len(q.groupMembers))
members = append(members, q.groupMembers...)
for _, org := range q.organizations {
for _, user := range q.users {
members = append(members, database.GroupMemberTable{
UserID: user.ID,
GroupID: org.ID,
})
}
}
var groupMembers []database.GroupMember
for _, member := range members {
groupMember, err := q.getGroupMemberNoLock(ctx, member.UserID, member.GroupID)
if errors.Is(err, errUserDeleted) {
continue
}
if err != nil {
return nil, err
}
groupMembers = append(groupMembers, groupMember)
}
return groupMembers, nil
}
func (q *FakeQuerier) GetGroupMembersByGroupID(_ context.Context, id uuid.UUID) ([]database.User, error) {
func (q *FakeQuerier) GetGroupMembersByGroupID(ctx context.Context, id uuid.UUID) ([]database.GroupMember, error) {
q.mutex.RLock()
defer q.mutex.RUnlock()
if q.isEveryoneGroup(id) {
return q.getEveryoneGroupMembersNoLock(id), nil
return q.getEveryoneGroupMembersNoLock(ctx, id), nil
}
var members []database.GroupMember
var groupMembers []database.GroupMember
for _, member := range q.groupMembers {
groupMember, err := q.getGroupMemberNoLock(ctx, member.UserID, member.GroupID)
if errors.Is(err, errUserDeleted) {
continue
}
if err != nil {
return nil, err
}
if member.GroupID == id {
members = append(members, member)
groupMembers = append(groupMembers, groupMember)
}
}
users := make([]database.User, 0, len(members))
return groupMembers, nil
}
for _, member := range members {
for _, user := range q.users {
if user.ID == member.UserID && !user.Deleted {
users = append(users, user)
break
}
}
func (q *FakeQuerier) GetGroupMembersCountByGroupID(ctx context.Context, groupID uuid.UUID) (int64, error) {
users, err := q.GetGroupMembersByGroupID(ctx, groupID)
if err != nil {
return 0, err
}
return users, nil
return int64(len(users)), nil
}
func (q *FakeQuerier) GetGroups(_ context.Context) ([]database.Group, error) {
@@ -2541,15 +2616,15 @@ func (q *FakeQuerier) GetGroupsByOrganizationAndUserID(_ context.Context, arg da
q.mutex.RLock()
defer q.mutex.RUnlock()
var groupIds []uuid.UUID
var groupIDs []uuid.UUID
for _, member := range q.groupMembers {
if member.UserID == arg.UserID {
groupIds = append(groupIds, member.GroupID)
groupIDs = append(groupIDs, member.GroupID)
}
}
groups := []database.Group{}
for _, group := range q.groups {
if slices.Contains(groupIds, group.ID) && group.OrganizationID == arg.OrganizationID {
if slices.Contains(groupIDs, group.ID) && group.OrganizationID == arg.OrganizationID {
groups = append(groups, group)
}
}
@@ -6234,7 +6309,7 @@ func (q *FakeQuerier) InsertGroupMember(_ context.Context, arg database.InsertGr
}
//nolint:gosimple
q.groupMembers = append(q.groupMembers, database.GroupMember{
q.groupMembers = append(q.groupMembers, database.GroupMemberTable{
GroupID: arg.GroupID,
UserID: arg.UserID,
})
@@ -6774,7 +6849,7 @@ func (q *FakeQuerier) InsertUserGroupsByName(_ context.Context, arg database.Ins
}
for _, groupID := range groupIDs {
q.groupMembers = append(q.groupMembers, database.GroupMember{
q.groupMembers = append(q.groupMembers, database.GroupMemberTable{
UserID: arg.UserID,
GroupID: groupID,
})
+8 -1
View File
@@ -648,13 +648,20 @@ func (m metricsStore) GetGroupMembers(ctx context.Context) ([]database.GroupMemb
return r0, r1
}
func (m metricsStore) GetGroupMembersByGroupID(ctx context.Context, groupID uuid.UUID) ([]database.User, error) {
func (m metricsStore) GetGroupMembersByGroupID(ctx context.Context, groupID uuid.UUID) ([]database.GroupMember, error) {
start := time.Now()
users, err := m.s.GetGroupMembersByGroupID(ctx, groupID)
m.queryLatencies.WithLabelValues("GetGroupMembersByGroupID").Observe(time.Since(start).Seconds())
return users, err
}
func (m metricsStore) GetGroupMembersCountByGroupID(ctx context.Context, groupID uuid.UUID) (int64, error) {
start := time.Now()
r0, r1 := m.s.GetGroupMembersCountByGroupID(ctx, groupID)
m.queryLatencies.WithLabelValues("GetGroupMembersCountByGroupID").Observe(time.Since(start).Seconds())
return r0, r1
}
func (m metricsStore) GetGroups(ctx context.Context) ([]database.Group, error) {
start := time.Now()
r0, r1 := m.s.GetGroups(ctx)
+17 -2
View File
@@ -1285,10 +1285,10 @@ func (mr *MockStoreMockRecorder) GetGroupMembers(arg0 any) *gomock.Call {
}
// GetGroupMembersByGroupID mocks base method.
func (m *MockStore) GetGroupMembersByGroupID(arg0 context.Context, arg1 uuid.UUID) ([]database.User, error) {
func (m *MockStore) GetGroupMembersByGroupID(arg0 context.Context, arg1 uuid.UUID) ([]database.GroupMember, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetGroupMembersByGroupID", arg0, arg1)
ret0, _ := ret[0].([]database.User)
ret0, _ := ret[0].([]database.GroupMember)
ret1, _ := ret[1].(error)
return ret0, ret1
}
@@ -1299,6 +1299,21 @@ func (mr *MockStoreMockRecorder) GetGroupMembersByGroupID(arg0, arg1 any) *gomoc
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetGroupMembersByGroupID", reflect.TypeOf((*MockStore)(nil).GetGroupMembersByGroupID), arg0, arg1)
}
// GetGroupMembersCountByGroupID mocks base method.
func (m *MockStore) GetGroupMembersCountByGroupID(arg0 context.Context, arg1 uuid.UUID) (int64, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetGroupMembersCountByGroupID", arg0, arg1)
ret0, _ := ret[0].(int64)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetGroupMembersCountByGroupID indicates an expected call of GetGroupMembersCountByGroupID.
func (mr *MockStoreMockRecorder) GetGroupMembersCountByGroupID(arg0, arg1 any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetGroupMembersCountByGroupID", reflect.TypeOf((*MockStore)(nil).GetGroupMembersCountByGroupID), arg0, arg1)
}
// GetGroups mocks base method.
func (m *MockStore) GetGroups(arg0 context.Context) ([]database.Group, error) {
m.ctrl.T.Helper()
+71 -35
View File
@@ -563,6 +563,77 @@ COMMENT ON COLUMN groups.display_name IS 'Display name is a custom, human-friend
COMMENT ON COLUMN groups.source IS 'Source indicates how the group was created. It can be created by a user manually, or through some system process like OIDC group sync.';
CREATE TABLE organization_members (
user_id uuid NOT NULL,
organization_id uuid NOT NULL,
created_at timestamp with time zone NOT NULL,
updated_at timestamp with time zone NOT NULL,
roles text[] DEFAULT '{}'::text[] NOT NULL
);
CREATE TABLE users (
id uuid NOT NULL,
email text NOT NULL,
username text DEFAULT ''::text NOT NULL,
hashed_password bytea NOT NULL,
created_at timestamp with time zone NOT NULL,
updated_at timestamp with time zone NOT NULL,
status user_status DEFAULT 'dormant'::user_status NOT NULL,
rbac_roles text[] DEFAULT '{}'::text[] NOT NULL,
login_type login_type DEFAULT 'password'::login_type NOT NULL,
avatar_url text DEFAULT ''::text NOT NULL,
deleted boolean DEFAULT false NOT NULL,
last_seen_at timestamp without time zone DEFAULT '0001-01-01 00:00:00'::timestamp without time zone NOT NULL,
quiet_hours_schedule text DEFAULT ''::text NOT NULL,
theme_preference text DEFAULT ''::text NOT NULL,
name text DEFAULT ''::text NOT NULL,
github_com_user_id bigint
);
COMMENT ON COLUMN users.quiet_hours_schedule IS 'Daily (!) cron schedule (with optional CRON_TZ) signifying the start of the user''s quiet hours. If empty, the default quiet hours on the instance is used instead.';
COMMENT ON COLUMN users.theme_preference IS '"" can be interpreted as "the user does not care", falling back to the default theme';
COMMENT ON COLUMN users.name IS 'Name of the Coder user';
COMMENT ON COLUMN users.github_com_user_id IS 'The GitHub.com numerical user ID. At time of implementation, this is used to check if the user has starred the Coder repository.';
CREATE VIEW group_members_expanded AS
WITH all_members AS (
SELECT group_members.user_id,
group_members.group_id
FROM group_members
UNION
SELECT organization_members.user_id,
organization_members.organization_id AS group_id
FROM organization_members
)
SELECT users.id AS user_id,
users.email AS user_email,
users.username AS user_username,
users.hashed_password AS user_hashed_password,
users.created_at AS user_created_at,
users.updated_at AS user_updated_at,
users.status AS user_status,
users.rbac_roles AS user_rbac_roles,
users.login_type AS user_login_type,
users.avatar_url AS user_avatar_url,
users.deleted AS user_deleted,
users.last_seen_at AS user_last_seen_at,
users.quiet_hours_schedule AS user_quiet_hours_schedule,
users.theme_preference AS user_theme_preference,
users.name AS user_name,
users.github_com_user_id AS user_github_com_user_id,
groups.organization_id,
groups.name AS group_name,
all_members.group_id
FROM ((all_members
JOIN users ON ((users.id = all_members.user_id)))
JOIN groups ON ((groups.id = all_members.group_id)))
WHERE (users.deleted = false);
COMMENT ON VIEW group_members_expanded IS 'Joins group members with user information, organization ID, group name. Includes both regular group members and organization members (as part of the "Everyone" group).';
CREATE TABLE jfrog_xray_scans (
agent_id uuid NOT NULL,
workspace_id uuid NOT NULL,
@@ -680,14 +751,6 @@ CREATE TABLE oauth2_provider_apps (
COMMENT ON TABLE oauth2_provider_apps IS 'A table used to configure apps that can use Coder as an OAuth2 provider, the reverse of what we are calling external authentication.';
CREATE TABLE organization_members (
user_id uuid NOT NULL,
organization_id uuid NOT NULL,
created_at timestamp with time zone NOT NULL,
updated_at timestamp with time zone NOT NULL,
roles text[] DEFAULT '{}'::text[] NOT NULL
);
CREATE TABLE organizations (
id uuid NOT NULL,
name text NOT NULL,
@@ -1014,33 +1077,6 @@ COMMENT ON COLUMN template_versions.external_auth_providers IS 'IDs of External
COMMENT ON COLUMN template_versions.message IS 'Message describing the changes in this version of the template, similar to a Git commit message. Like a commit message, this should be a short, high-level description of the changes in this version of the template. This message is immutable and should not be updated after the fact.';
CREATE TABLE users (
id uuid NOT NULL,
email text NOT NULL,
username text DEFAULT ''::text NOT NULL,
hashed_password bytea NOT NULL,
created_at timestamp with time zone NOT NULL,
updated_at timestamp with time zone NOT NULL,
status user_status DEFAULT 'dormant'::user_status NOT NULL,
rbac_roles text[] DEFAULT '{}'::text[] NOT NULL,
login_type login_type DEFAULT 'password'::login_type NOT NULL,
avatar_url text DEFAULT ''::text NOT NULL,
deleted boolean DEFAULT false NOT NULL,
last_seen_at timestamp without time zone DEFAULT '0001-01-01 00:00:00'::timestamp without time zone NOT NULL,
quiet_hours_schedule text DEFAULT ''::text NOT NULL,
theme_preference text DEFAULT ''::text NOT NULL,
name text DEFAULT ''::text NOT NULL,
github_com_user_id bigint
);
COMMENT ON COLUMN users.quiet_hours_schedule IS 'Daily (!) cron schedule (with optional CRON_TZ) signifying the start of the user''s quiet hours. If empty, the default quiet hours on the instance is used instead.';
COMMENT ON COLUMN users.theme_preference IS '"" can be interpreted as "the user does not care", falling back to the default theme';
COMMENT ON COLUMN users.name IS 'Name of the Coder user';
COMMENT ON COLUMN users.github_com_user_id IS 'The GitHub.com numerical user ID. At time of implementation, this is used to check if the user has starred the Coder repository.';
CREATE VIEW visible_users AS
SELECT users.id,
users.username,
@@ -0,0 +1 @@
DROP VIEW group_members_expanded;
@@ -0,0 +1,40 @@
CREATE VIEW
group_members_expanded
AS
-- If the group is a user made group, then we need to check the group_members table.
-- If it is the "Everyone" group, then we need to check the organization_members table.
WITH all_members AS (
SELECT user_id, group_id FROM group_members
UNION
SELECT user_id, organization_id AS group_id FROM organization_members
)
SELECT
users.id AS user_id,
users.email AS user_email,
users.username AS user_username,
users.hashed_password AS user_hashed_password,
users.created_at AS user_created_at,
users.updated_at AS user_updated_at,
users.status AS user_status,
users.rbac_roles AS user_rbac_roles,
users.login_type AS user_login_type,
users.avatar_url AS user_avatar_url,
users.deleted AS user_deleted,
users.last_seen_at AS user_last_seen_at,
users.quiet_hours_schedule AS user_quiet_hours_schedule,
users.theme_preference AS user_theme_preference,
users.name AS user_name,
users.github_com_user_id AS user_github_com_user_id,
groups.organization_id AS organization_id,
groups.name AS group_name,
all_members.group_id AS group_id
FROM
all_members
JOIN
users ON users.id = all_members.user_id
JOIN
groups ON groups.id = all_members.group_id
WHERE
users.deleted = 'false';
COMMENT ON VIEW group_members_expanded IS 'Joins group members with user information, organization ID, group name. Includes both regular group members and organization members (as part of the "Everyone" group).';
+21 -10
View File
@@ -12,6 +12,7 @@ import (
"github.com/coder/coder/v2/coderd/database/dbtime"
"github.com/coder/coder/v2/coderd/rbac"
"github.com/coder/coder/v2/coderd/rbac/policy"
)
type WorkspaceStatus string
@@ -75,18 +76,18 @@ func (m OrganizationMember) Auditable(username string) AuditableOrganizationMemb
type AuditableGroup struct {
Group
Members []GroupMember `json:"members"`
Members []GroupMemberTable `json:"members"`
}
// Auditable returns an object that can be used in audit logs.
// Covers both group and group member changes.
func (g Group) Auditable(users []User) AuditableGroup {
members := make([]GroupMember, 0, len(users))
for _, u := range users {
members = append(members, GroupMember{
UserID: u.ID,
GroupID: g.ID,
})
func (g Group) Auditable(members []GroupMember) AuditableGroup {
membersTable := make([]GroupMemberTable, len(members))
for i, member := range members {
membersTable[i] = GroupMemberTable{
UserID: member.UserID,
GroupID: member.GroupID,
}
}
// consistent ordering
@@ -96,7 +97,7 @@ func (g Group) Auditable(users []User) AuditableGroup {
return AuditableGroup{
Group: g,
Members: members,
Members: membersTable,
}
}
@@ -173,7 +174,17 @@ func (v TemplateVersion) RBACObjectNoTemplate() rbac.Object {
func (g Group) RBACObject() rbac.Object {
return rbac.ResourceGroup.WithID(g.ID).
InOrg(g.OrganizationID)
InOrg(g.OrganizationID).
// Group members can read the group.
WithGroupACL(map[string][]policy.Action{
g.ID.String(): {
policy.ActionRead,
},
})
}
func (gm GroupMember) RBACObject() rbac.Object {
return rbac.ResourceGroupMember.WithID(gm.UserID).InOrg(gm.OrganizationID).WithOwner(gm.UserID.String())
}
func (w GetWorkspaceByAgentIDRow) RBACObject() rbac.Object {
+23
View File
@@ -2054,7 +2054,30 @@ type Group struct {
Source GroupSource `db:"source" json:"source"`
}
// Joins group members with user information, organization ID, group name. Includes both regular group members and organization members (as part of the "Everyone" group).
type GroupMember struct {
UserID uuid.UUID `db:"user_id" json:"user_id"`
UserEmail string `db:"user_email" json:"user_email"`
UserUsername string `db:"user_username" json:"user_username"`
UserHashedPassword []byte `db:"user_hashed_password" json:"user_hashed_password"`
UserCreatedAt time.Time `db:"user_created_at" json:"user_created_at"`
UserUpdatedAt time.Time `db:"user_updated_at" json:"user_updated_at"`
UserStatus UserStatus `db:"user_status" json:"user_status"`
UserRbacRoles []string `db:"user_rbac_roles" json:"user_rbac_roles"`
UserLoginType LoginType `db:"user_login_type" json:"user_login_type"`
UserAvatarUrl string `db:"user_avatar_url" json:"user_avatar_url"`
UserDeleted bool `db:"user_deleted" json:"user_deleted"`
UserLastSeenAt time.Time `db:"user_last_seen_at" json:"user_last_seen_at"`
UserQuietHoursSchedule string `db:"user_quiet_hours_schedule" json:"user_quiet_hours_schedule"`
UserThemePreference string `db:"user_theme_preference" json:"user_theme_preference"`
UserName string `db:"user_name" json:"user_name"`
UserGithubComUserID sql.NullInt64 `db:"user_github_com_user_id" json:"user_github_com_user_id"`
OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"`
GroupName string `db:"group_name" json:"group_name"`
GroupID uuid.UUID `db:"group_id" json:"group_id"`
}
type GroupMemberTable struct {
UserID uuid.UUID `db:"user_id" json:"user_id"`
GroupID uuid.UUID `db:"group_id" json:"group_id"`
}
+5 -3
View File
@@ -146,9 +146,11 @@ type sqlcQuerier interface {
GetGroupByID(ctx context.Context, id uuid.UUID) (Group, error)
GetGroupByOrgAndName(ctx context.Context, arg GetGroupByOrgAndNameParams) (Group, error)
GetGroupMembers(ctx context.Context) ([]GroupMember, error)
// If the group is a user made group, then we need to check the group_members table.
// If it is the "Everyone" group, then we need to check the organization_members table.
GetGroupMembersByGroupID(ctx context.Context, groupID uuid.UUID) ([]User, error)
GetGroupMembersByGroupID(ctx context.Context, groupID uuid.UUID) ([]GroupMember, error)
// Returns the total count of members in a group. Shows the total
// count even if the caller does not have read access to ResourceGroupMember.
// They only need ResourceGroup read access.
GetGroupMembersCountByGroupID(ctx context.Context, groupID uuid.UUID) (int64, error)
GetGroups(ctx context.Context) ([]Group, error)
GetGroupsByOrganizationAndUserID(ctx context.Context, arg GetGroupsByOrganizationAndUserIDParams) ([]Group, error)
GetGroupsByOrganizationID(ctx context.Context, organizationID uuid.UUID) ([]Group, error)
+69 -61
View File
@@ -1322,7 +1322,7 @@ func (q *sqlQuerier) DeleteGroupMemberFromGroup(ctx context.Context, arg DeleteG
}
const getGroupMembers = `-- name: GetGroupMembers :many
SELECT user_id, group_id FROM group_members
SELECT user_id, user_email, user_username, user_hashed_password, user_created_at, user_updated_at, user_status, user_rbac_roles, user_login_type, user_avatar_url, user_deleted, user_last_seen_at, user_quiet_hours_schedule, user_theme_preference, user_name, user_github_com_user_id, organization_id, group_name, group_id FROM group_members_expanded
`
func (q *sqlQuerier) GetGroupMembers(ctx context.Context) ([]GroupMember, error) {
@@ -1334,7 +1334,27 @@ func (q *sqlQuerier) GetGroupMembers(ctx context.Context) ([]GroupMember, error)
var items []GroupMember
for rows.Next() {
var i GroupMember
if err := rows.Scan(&i.UserID, &i.GroupID); err != nil {
if err := rows.Scan(
&i.UserID,
&i.UserEmail,
&i.UserUsername,
&i.UserHashedPassword,
&i.UserCreatedAt,
&i.UserUpdatedAt,
&i.UserStatus,
pq.Array(&i.UserRbacRoles),
&i.UserLoginType,
&i.UserAvatarUrl,
&i.UserDeleted,
&i.UserLastSeenAt,
&i.UserQuietHoursSchedule,
&i.UserThemePreference,
&i.UserName,
&i.UserGithubComUserID,
&i.OrganizationID,
&i.GroupName,
&i.GroupID,
); err != nil {
return nil, err
}
items = append(items, i)
@@ -1349,57 +1369,38 @@ func (q *sqlQuerier) GetGroupMembers(ctx context.Context) ([]GroupMember, error)
}
const getGroupMembersByGroupID = `-- name: GetGroupMembersByGroupID :many
SELECT
users.id, users.email, users.username, users.hashed_password, users.created_at, users.updated_at, users.status, users.rbac_roles, users.login_type, users.avatar_url, users.deleted, users.last_seen_at, users.quiet_hours_schedule, users.theme_preference, users.name, users.github_com_user_id
FROM
users
LEFT JOIN
group_members
ON
group_members.user_id = users.id AND
group_members.group_id = $1
LEFT JOIN
organization_members
ON
organization_members.user_id = users.id AND
organization_members.organization_id = $1
WHERE
-- In either case, the group_id will only match an org or a group.
(group_members.group_id = $1
OR
organization_members.organization_id = $1)
AND
users.deleted = 'false'
SELECT user_id, user_email, user_username, user_hashed_password, user_created_at, user_updated_at, user_status, user_rbac_roles, user_login_type, user_avatar_url, user_deleted, user_last_seen_at, user_quiet_hours_schedule, user_theme_preference, user_name, user_github_com_user_id, organization_id, group_name, group_id FROM group_members_expanded WHERE group_id = $1
`
// If the group is a user made group, then we need to check the group_members table.
// If it is the "Everyone" group, then we need to check the organization_members table.
func (q *sqlQuerier) GetGroupMembersByGroupID(ctx context.Context, groupID uuid.UUID) ([]User, error) {
func (q *sqlQuerier) GetGroupMembersByGroupID(ctx context.Context, groupID uuid.UUID) ([]GroupMember, error) {
rows, err := q.db.QueryContext(ctx, getGroupMembersByGroupID, groupID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []User
var items []GroupMember
for rows.Next() {
var i User
var i GroupMember
if err := rows.Scan(
&i.ID,
&i.Email,
&i.Username,
&i.HashedPassword,
&i.CreatedAt,
&i.UpdatedAt,
&i.Status,
&i.RBACRoles,
&i.LoginType,
&i.AvatarURL,
&i.Deleted,
&i.LastSeenAt,
&i.QuietHoursSchedule,
&i.ThemePreference,
&i.Name,
&i.GithubComUserID,
&i.UserID,
&i.UserEmail,
&i.UserUsername,
&i.UserHashedPassword,
&i.UserCreatedAt,
&i.UserUpdatedAt,
&i.UserStatus,
pq.Array(&i.UserRbacRoles),
&i.UserLoginType,
&i.UserAvatarUrl,
&i.UserDeleted,
&i.UserLastSeenAt,
&i.UserQuietHoursSchedule,
&i.UserThemePreference,
&i.UserName,
&i.UserGithubComUserID,
&i.OrganizationID,
&i.GroupName,
&i.GroupID,
); err != nil {
return nil, err
}
@@ -1414,6 +1415,20 @@ func (q *sqlQuerier) GetGroupMembersByGroupID(ctx context.Context, groupID uuid.
return items, nil
}
const getGroupMembersCountByGroupID = `-- name: GetGroupMembersCountByGroupID :one
SELECT COUNT(*) FROM group_members_expanded WHERE group_id = $1
`
// Returns the total count of members in a group. Shows the total
// count even if the caller does not have read access to ResourceGroupMember.
// They only need ResourceGroup read access.
func (q *sqlQuerier) GetGroupMembersCountByGroupID(ctx context.Context, groupID uuid.UUID) (int64, error) {
row := q.db.QueryRowContext(ctx, getGroupMembersCountByGroupID, groupID)
var count int64
err := row.Scan(&count)
return count, err
}
const insertGroupMember = `-- name: InsertGroupMember :exec
INSERT INTO
group_members (user_id, group_id)
@@ -1585,24 +1600,17 @@ SELECT
groups.id, groups.name, groups.organization_id, groups.avatar_url, groups.quota_allowance, groups.display_name, groups.source
FROM
groups
-- If the group is a user made group, then we need to check the group_members table.
LEFT JOIN
group_members
ON
group_members.group_id = groups.id AND
group_members.user_id = $1
-- If it is the "Everyone" group, then we need to check the organization_members table.
LEFT JOIN
organization_members
ON
organization_members.organization_id = groups.id AND
organization_members.user_id = $1
WHERE
-- In either case, the group_id will only match an org or a group.
(group_members.user_id = $1 OR organization_members.user_id = $1)
AND
-- Ensure the group or organization is the specified organization.
groups.organization_id = $2
groups.id IN (
SELECT
group_id
FROM
group_members_expanded gme
WHERE
gme.user_id = $1
AND
gme.organization_id = $2
)
`
type GetGroupsByOrganizationAndUserIDParams struct {
+8 -24
View File
@@ -1,30 +1,14 @@
-- name: GetGroupMembers :many
SELECT * FROM group_members;
SELECT * FROM group_members_expanded;
-- name: GetGroupMembersByGroupID :many
SELECT
users.*
FROM
users
-- If the group is a user made group, then we need to check the group_members table.
LEFT JOIN
group_members
ON
group_members.user_id = users.id AND
group_members.group_id = @group_id
-- If it is the "Everyone" group, then we need to check the organization_members table.
LEFT JOIN
organization_members
ON
organization_members.user_id = users.id AND
organization_members.organization_id = @group_id
WHERE
-- In either case, the group_id will only match an org or a group.
(group_members.group_id = @group_id
OR
organization_members.organization_id = @group_id)
AND
users.deleted = 'false';
SELECT * FROM group_members_expanded WHERE group_id = @group_id;
-- name: GetGroupMembersCountByGroupID :one
-- Returns the total count of members in a group. Shows the total
-- count even if the caller does not have read access to ResourceGroupMember.
-- They only need ResourceGroup read access.
SELECT COUNT(*) FROM group_members_expanded WHERE group_id = @group_id;
-- InsertUserGroupsByName adds a user to all provided groups, if they exist.
-- name: InsertUserGroupsByName :exec
+10 -18
View File
@@ -36,25 +36,17 @@ SELECT
groups.*
FROM
groups
-- If the group is a user made group, then we need to check the group_members table.
LEFT JOIN
group_members
ON
group_members.group_id = groups.id AND
group_members.user_id = @user_id
-- If it is the "Everyone" group, then we need to check the organization_members table.
LEFT JOIN
organization_members
ON
organization_members.organization_id = groups.id AND
organization_members.user_id = @user_id
WHERE
-- In either case, the group_id will only match an org or a group.
(group_members.user_id = @user_id OR organization_members.user_id = @user_id)
AND
-- Ensure the group or organization is the specified organization.
groups.organization_id = @organization_id;
groups.id IN (
SELECT
group_id
FROM
group_members_expanded gme
WHERE
gme.user_id = @user_id
AND
gme.organization_id = @organization_id
);
-- name: InsertGroup :one
INSERT INTO groups (
+2
View File
@@ -74,6 +74,8 @@ sql:
go_type:
type: "[]byte"
rename:
group_member: GroupMemberTable
group_members_expanded: GroupMember
template: TemplateTable
template_with_name: Template
workspace_build: WorkspaceBuildTable