diff --git a/coderd/coderdtest/authorize.go b/coderd/coderdtest/authorize.go
index 5e3918e7e6f02..83a3cd40d649e 100644
--- a/coderd/coderdtest/authorize.go
+++ b/coderd/coderdtest/authorize.go
@@ -116,7 +116,7 @@ func (RBACAsserter) convertObjects(t *testing.T, objs ...interface{}) []rbac.Obj
case codersdk.TemplateVersion:
robj = rbac.ResourceTemplate.InOrg(obj.OrganizationID)
case codersdk.User:
- robj = rbac.ResourceUser.WithID(obj.ID)
+ robj = rbac.ResourceUser.WithID(obj.ID).WithOwner(obj.ID.String())
case codersdk.Workspace:
robj = rbac.ResourceWorkspace.WithID(obj.ID).InOrg(obj.OrganizationID).WithOwner(obj.OwnerID.String())
default:
diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go
index 41fa20392fadf..3124bad7cd38d 100644
--- a/coderd/database/dbauthz/dbauthz.go
+++ b/coderd/database/dbauthz/dbauthz.go
@@ -575,11 +575,6 @@ func (q *querier) canAssignRoles(ctx context.Context, orgID *uuid.UUID, added, r
return nil
}
-func (q *querier) GetAuthorizedTemplates(ctx context.Context, arg database.GetTemplatesWithFilterParams, _ rbac.PreparedAuthorized) ([]database.Template, error) {
- // TODO Delete this function, all GetTemplates should be authorized. For now just call getTemplates on the authz querier.
- return q.GetTemplatesWithFilter(ctx, arg)
-}
-
func (q *querier) SoftDeleteTemplateByID(ctx context.Context, id uuid.UUID) error {
deleteF := func(ctx context.Context, id uuid.UUID) error {
return q.db.UpdateTemplateDeletedByID(ctx, database.UpdateTemplateDeletedByIDParams{
@@ -591,37 +586,13 @@ func (q *querier) SoftDeleteTemplateByID(ctx context.Context, id uuid.UUID) erro
return deleteQ(q.log, q.auth, q.db.GetTemplateByID, deleteF)(ctx, id)
}
-func (q *querier) GetTemplateGroupRoles(ctx context.Context, id uuid.UUID) ([]database.TemplateGroup, error) {
- // An actor is authorized to read template group roles if they are authorized to read the template.
- template, err := q.db.GetTemplateByID(ctx, id)
+func (q *querier) GetUsersWithCount(ctx context.Context, arg database.GetUsersParams) ([]database.User, int64, error) {
+ prep, err := prepareSQLFilter(ctx, q.auth, rbac.ActionRead, rbac.ResourceUser.Type)
if err != nil {
- return nil, err
+ return nil, -1, xerrors.Errorf("failed to prepare sql filter: %w", err)
}
- if err := q.authorizeContext(ctx, rbac.ActionRead, template); err != nil {
- return nil, err
- }
- return q.db.GetTemplateGroupRoles(ctx, id)
-}
-func (q *querier) GetTemplateUserRoles(ctx context.Context, id uuid.UUID) ([]database.TemplateUser, error) {
- // An actor is authorized to query template user roles if they are authorized to read the template.
- template, err := q.db.GetTemplateByID(ctx, id)
- if err != nil {
- return nil, err
- }
- if err := q.authorizeContext(ctx, rbac.ActionRead, template); err != nil {
- return nil, err
- }
- return q.db.GetTemplateUserRoles(ctx, id)
-}
-
-func (q *querier) GetAuthorizedUserCount(ctx context.Context, arg database.GetFilteredUserCountParams, prepared rbac.PreparedAuthorized) (int64, error) {
- return q.db.GetAuthorizedUserCount(ctx, arg, prepared)
-}
-
-func (q *querier) GetUsersWithCount(ctx context.Context, arg database.GetUsersParams) ([]database.User, int64, error) {
- // TODO Implement this with a SQL filter. The count is incorrect without it.
- rowUsers, err := q.db.GetUsers(ctx, arg)
+ rowUsers, err := q.db.GetAuthorizedUsers(ctx, arg, prep)
if err != nil {
return nil, -1, err
}
@@ -630,18 +601,8 @@ func (q *querier) GetUsersWithCount(ctx context.Context, arg database.GetUsersPa
return []database.User{}, 0, nil
}
- act, ok := ActorFromContext(ctx)
- if !ok {
- return nil, -1, NoActorError
- }
-
// TODO: Is this correct? Should we return a restricted user?
users := database.ConvertUserRows(rowUsers)
- users, err = rbac.Filter(ctx, q.auth, act, rbac.ActionRead, users)
- if err != nil {
- return nil, -1, err
- }
-
return users, rowUsers[0].Count, nil
}
@@ -655,11 +616,6 @@ func (q *querier) SoftDeleteUserByID(ctx context.Context, id uuid.UUID) error {
return deleteQ(q.log, q.auth, q.db.GetUserByID, deleteF)(ctx, id)
}
-func (q *querier) GetAuthorizedWorkspaces(ctx context.Context, arg database.GetWorkspacesParams, _ rbac.PreparedAuthorized) ([]database.GetWorkspacesRow, error) {
- // TODO Delete this function, all GetWorkspaces should be authorized. For now just call GetWorkspaces on the authz querier.
- return q.GetWorkspaces(ctx, arg)
-}
-
func (q *querier) SoftDeleteWorkspaceByID(ctx context.Context, id uuid.UUID) error {
return deleteQ(q.log, q.auth, q.db.GetWorkspaceByID, func(ctx context.Context, id uuid.UUID) error {
return q.db.UpdateWorkspaceDeletedByID(ctx, database.UpdateWorkspaceDeletedByIDParams{
@@ -1181,7 +1137,7 @@ func (q *querier) GetProvisionerLogsAfterID(ctx context.Context, arg database.Ge
}
func (q *querier) GetQuotaAllowanceForUser(ctx context.Context, userID uuid.UUID) (int64, error) {
- err := q.authorizeContext(ctx, rbac.ActionRead, rbac.ResourceUser.WithID(userID))
+ err := q.authorizeContext(ctx, rbac.ActionRead, rbac.ResourceUser.WithID(userID).WithOwner(userID.String()))
if err != nil {
return -1, err
}
@@ -1189,7 +1145,7 @@ func (q *querier) GetQuotaAllowanceForUser(ctx context.Context, userID uuid.UUID
}
func (q *querier) GetQuotaConsumedForUser(ctx context.Context, userID uuid.UUID) (int64, error) {
- err := q.authorizeContext(ctx, rbac.ActionRead, rbac.ResourceUser.WithID(userID))
+ err := q.authorizeContext(ctx, rbac.ActionRead, rbac.ResourceUser.WithID(userID).WithOwner(userID.String()))
if err != nil {
return -1, err
}
@@ -1427,8 +1383,12 @@ func (q *querier) GetUserLinkByUserIDLoginType(ctx context.Context, arg database
}
func (q *querier) GetUsers(ctx context.Context, arg database.GetUsersParams) ([]database.GetUsersRow, error) {
- // TODO: We should use GetUsersWithCount with a better method signature.
- return fetchWithPostFilter(q.auth, q.db.GetUsers)(ctx, arg)
+ // This does the filtering in SQL.
+ prep, err := prepareSQLFilter(ctx, q.auth, rbac.ActionRead, rbac.ResourceUser.Type)
+ if err != nil {
+ return nil, xerrors.Errorf("(dev error) prepare sql filter: %w", err)
+ }
+ return q.db.GetAuthorizedUsers(ctx, arg, prep)
}
// GetUsersByIDs is only used for usernames on workspace return data.
@@ -1436,7 +1396,7 @@ func (q *querier) GetUsers(ctx context.Context, arg database.GetUsersParams) ([]
// itself.
func (q *querier) GetUsersByIDs(ctx context.Context, ids []uuid.UUID) ([]database.User, error) {
for _, uid := range ids {
- if err := q.authorizeContext(ctx, rbac.ActionRead, rbac.ResourceUser.WithID(uid)); err != nil {
+ if err := q.authorizeContext(ctx, rbac.ActionRead, rbac.ResourceUser.WithID(uid).WithOwner(uid.String())); err != nil {
return nil, err
}
}
@@ -1942,7 +1902,7 @@ func (q *querier) InsertUserGroupsByName(ctx context.Context, arg database.Inser
// TODO: Should this be in system.go?
func (q *querier) InsertUserLink(ctx context.Context, arg database.InsertUserLinkParams) (database.UserLink, error) {
- if err := q.authorizeContext(ctx, rbac.ActionUpdate, rbac.ResourceUser.WithID(arg.UserID)); err != nil {
+ if err := q.authorizeContext(ctx, rbac.ActionUpdate, rbac.ResourceUser.WithID(arg.UserID).WithOwner(arg.UserID.String())); err != nil {
return database.UserLink{}, err
}
return q.db.InsertUserLink(ctx, arg)
@@ -2642,3 +2602,48 @@ func (q *querier) UpsertTailnetCoordinator(ctx context.Context, id uuid.UUID) (d
}
return q.db.UpsertTailnetCoordinator(ctx, id)
}
+
+func (q *querier) GetAuthorizedTemplates(ctx context.Context, arg database.GetTemplatesWithFilterParams, _ rbac.PreparedAuthorized) ([]database.Template, error) {
+ // TODO Delete this function, all GetTemplates should be authorized. For now just call getTemplates on the authz querier.
+ return q.GetTemplatesWithFilter(ctx, arg)
+}
+
+func (q *querier) GetTemplateGroupRoles(ctx context.Context, id uuid.UUID) ([]database.TemplateGroup, error) {
+ // An actor is authorized to read template group roles if they are authorized to read the template.
+ template, err := q.db.GetTemplateByID(ctx, id)
+ if err != nil {
+ return nil, err
+ }
+ if err := q.authorizeContext(ctx, rbac.ActionRead, template); err != nil {
+ return nil, err
+ }
+ return q.db.GetTemplateGroupRoles(ctx, id)
+}
+
+func (q *querier) GetTemplateUserRoles(ctx context.Context, id uuid.UUID) ([]database.TemplateUser, error) {
+ // An actor is authorized to query template user roles if they are authorized to read the template.
+ template, err := q.db.GetTemplateByID(ctx, id)
+ if err != nil {
+ return nil, err
+ }
+ if err := q.authorizeContext(ctx, rbac.ActionRead, template); err != nil {
+ return nil, err
+ }
+ return q.db.GetTemplateUserRoles(ctx, id)
+}
+
+func (q *querier) GetAuthorizedWorkspaces(ctx context.Context, arg database.GetWorkspacesParams, _ rbac.PreparedAuthorized) ([]database.GetWorkspacesRow, error) {
+ // TODO Delete this function, all GetWorkspaces should be authorized. For now just call GetWorkspaces on the authz querier.
+ return q.GetWorkspaces(ctx, arg)
+}
+
+// GetAuthorizedUsers is not required for dbauthz since GetUsers is already
+// authenticated.
+func (q *querier) GetAuthorizedUsers(ctx context.Context, arg database.GetUsersParams, _ rbac.PreparedAuthorized) ([]database.GetUsersRow, error) {
+ // GetUsers is authenticated.
+ return q.GetUsers(ctx, arg)
+}
+
+func (q *querier) GetAuthorizedUserCount(ctx context.Context, arg database.GetFilteredUserCountParams, prepared rbac.PreparedAuthorized) (int64, error) {
+ return q.db.GetAuthorizedUserCount(ctx, arg, prepared)
+}
diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go
index bde4a1dfd5ef4..e9079491b4736 100644
--- a/coderd/database/dbauthz/dbauthz_test.go
+++ b/coderd/database/dbauthz/dbauthz_test.go
@@ -521,7 +521,7 @@ func (s *MethodTestSuite) TestOrganization() {
ma := dbgen.OrganizationMember(s.T(), db, database.OrganizationMember{OrganizationID: oa.ID})
mb := dbgen.OrganizationMember(s.T(), db, database.OrganizationMember{OrganizationID: ob.ID})
check.Args([]uuid.UUID{ma.UserID, mb.UserID}).
- Asserts(rbac.ResourceUser.WithID(ma.UserID), rbac.ActionRead, rbac.ResourceUser.WithID(mb.UserID), rbac.ActionRead)
+ Asserts(rbac.ResourceUser.WithID(ma.UserID).WithOwner(ma.UserID.String()), rbac.ActionRead, rbac.ResourceUser.WithID(mb.UserID).WithOwner(mb.UserID.String()), rbac.ActionRead)
}))
s.Run("GetOrganizationMemberByUserID", s.Subtest(func(db database.Store, check *expects) {
mem := dbgen.OrganizationMember(s.T(), db, database.OrganizationMember{})
diff --git a/coderd/database/dbfake/dbfake.go b/coderd/database/dbfake/dbfake.go
index bdcfd9366dabb..a09787a49553e 100644
--- a/coderd/database/dbfake/dbfake.go
+++ b/coderd/database/dbfake/dbfake.go
@@ -5437,3 +5437,38 @@ func (*FakeQuerier) UpsertTailnetClient(context.Context, database.UpsertTailnetC
func (*FakeQuerier) UpsertTailnetCoordinator(context.Context, uuid.UUID) (database.TailnetCoordinator, error) {
return database.TailnetCoordinator{}, ErrUnimplemented
}
+
+func (q *fakeQuerier) GetAuthorizedUsers(ctx context.Context, arg database.GetUsersParams, prepared rbac.PreparedAuthorized) ([]database.GetUsersRow, error) {
+ if err := validateDatabaseType(arg); err != nil {
+ return nil, err
+ }
+
+ // Call this to match the same function calls as the SQL implementation.
+ if prepared != nil {
+ _, err := prepared.CompileToSQL(ctx, regosql.ConvertConfig{
+ VariableConverter: regosql.UserConverter(),
+ })
+ if err != nil {
+ return nil, err
+ }
+ }
+
+ users, err := q.GetUsers(ctx, arg)
+ if err != nil {
+ return nil, err
+ }
+
+ q.mutex.RLock()
+ defer q.mutex.RUnlock()
+
+ filteredUsers := make([]database.GetUsersRow, 0, len(users))
+ for _, user := range users {
+ // If the filter exists, ensure the object is authorized.
+ if prepared != nil && prepared.Authorize(ctx, user.RBACObject()) != nil {
+ continue
+ }
+
+ filteredUsers = append(filteredUsers, user)
+ }
+ return filteredUsers, nil
+}
diff --git a/coderd/database/dbmetrics/dbmetrics.go b/coderd/database/dbmetrics/dbmetrics.go
index ec28fd428a102..1bbbf42b3ab16 100644
--- a/coderd/database/dbmetrics/dbmetrics.go
+++ b/coderd/database/dbmetrics/dbmetrics.go
@@ -73,41 +73,6 @@ func (m metricsStore) InTx(f func(database.Store) error, options *sql.TxOptions)
return err
}
-func (m metricsStore) GetAuthorizedTemplates(ctx context.Context, arg database.GetTemplatesWithFilterParams, prepared rbac.PreparedAuthorized) ([]database.Template, error) {
- start := time.Now()
- templates, err := m.s.GetAuthorizedTemplates(ctx, arg, prepared)
- m.queryLatencies.WithLabelValues("GetAuthorizedTemplates").Observe(time.Since(start).Seconds())
- return templates, err
-}
-
-func (m metricsStore) GetTemplateGroupRoles(ctx context.Context, id uuid.UUID) ([]database.TemplateGroup, error) {
- start := time.Now()
- roles, err := m.s.GetTemplateGroupRoles(ctx, id)
- m.queryLatencies.WithLabelValues("GetTemplateGroupRoles").Observe(time.Since(start).Seconds())
- return roles, err
-}
-
-func (m metricsStore) GetTemplateUserRoles(ctx context.Context, id uuid.UUID) ([]database.TemplateUser, error) {
- start := time.Now()
- roles, err := m.s.GetTemplateUserRoles(ctx, id)
- m.queryLatencies.WithLabelValues("GetTemplateUserRoles").Observe(time.Since(start).Seconds())
- return roles, err
-}
-
-func (m metricsStore) GetAuthorizedWorkspaces(ctx context.Context, arg database.GetWorkspacesParams, prepared rbac.PreparedAuthorized) ([]database.GetWorkspacesRow, error) {
- start := time.Now()
- workspaces, err := m.s.GetAuthorizedWorkspaces(ctx, arg, prepared)
- m.queryLatencies.WithLabelValues("GetAuthorizedWorkspaces").Observe(time.Since(start).Seconds())
- return workspaces, err
-}
-
-func (m metricsStore) GetAuthorizedUserCount(ctx context.Context, arg database.GetFilteredUserCountParams, prepared rbac.PreparedAuthorized) (int64, error) {
- start := time.Now()
- count, err := m.s.GetAuthorizedUserCount(ctx, arg, prepared)
- m.queryLatencies.WithLabelValues("GetAuthorizedUserCount").Observe(time.Since(start).Seconds())
- return count, err
-}
-
func (m metricsStore) AcquireLock(ctx context.Context, pgAdvisoryXactLock int64) error {
start := time.Now()
err := m.s.AcquireLock(ctx, pgAdvisoryXactLock)
@@ -1639,3 +1604,45 @@ func (m metricsStore) UpsertTailnetCoordinator(ctx context.Context, id uuid.UUID
defer m.queryLatencies.WithLabelValues("UpsertTailnetCoordinator").Observe(time.Since(start).Seconds())
return m.s.UpsertTailnetCoordinator(ctx, id)
}
+
+func (m metricsStore) GetAuthorizedTemplates(ctx context.Context, arg database.GetTemplatesWithFilterParams, prepared rbac.PreparedAuthorized) ([]database.Template, error) {
+ start := time.Now()
+ templates, err := m.s.GetAuthorizedTemplates(ctx, arg, prepared)
+ m.queryLatencies.WithLabelValues("GetAuthorizedTemplates").Observe(time.Since(start).Seconds())
+ return templates, err
+}
+
+func (m metricsStore) GetTemplateGroupRoles(ctx context.Context, id uuid.UUID) ([]database.TemplateGroup, error) {
+ start := time.Now()
+ roles, err := m.s.GetTemplateGroupRoles(ctx, id)
+ m.queryLatencies.WithLabelValues("GetTemplateGroupRoles").Observe(time.Since(start).Seconds())
+ return roles, err
+}
+
+func (m metricsStore) GetTemplateUserRoles(ctx context.Context, id uuid.UUID) ([]database.TemplateUser, error) {
+ start := time.Now()
+ roles, err := m.s.GetTemplateUserRoles(ctx, id)
+ m.queryLatencies.WithLabelValues("GetTemplateUserRoles").Observe(time.Since(start).Seconds())
+ return roles, err
+}
+
+func (m metricsStore) GetAuthorizedWorkspaces(ctx context.Context, arg database.GetWorkspacesParams, prepared rbac.PreparedAuthorized) ([]database.GetWorkspacesRow, error) {
+ start := time.Now()
+ workspaces, err := m.s.GetAuthorizedWorkspaces(ctx, arg, prepared)
+ m.queryLatencies.WithLabelValues("GetAuthorizedWorkspaces").Observe(time.Since(start).Seconds())
+ return workspaces, err
+}
+
+func (m metricsStore) GetAuthorizedUsers(ctx context.Context, arg database.GetUsersParams, prepared rbac.PreparedAuthorized) ([]database.GetUsersRow, error) {
+ start := time.Now()
+ r0, r1 := m.s.GetAuthorizedUsers(ctx, arg, prepared)
+ m.queryLatencies.WithLabelValues("GetAuthorizedUsers").Observe(time.Since(start).Seconds())
+ return r0, r1
+}
+
+func (m metricsStore) GetAuthorizedUserCount(ctx context.Context, arg database.GetFilteredUserCountParams, prepared rbac.PreparedAuthorized) (int64, error) {
+ start := time.Now()
+ count, err := m.s.GetAuthorizedUserCount(ctx, arg, prepared)
+ m.queryLatencies.WithLabelValues("GetAuthorizedUserCount").Observe(time.Since(start).Seconds())
+ return count, err
+}
diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go
index deab31927154f..9d635bd77e0e4 100644
--- a/coderd/database/dbmock/dbmock.go
+++ b/coderd/database/dbmock/dbmock.go
@@ -446,6 +446,21 @@ func (mr *MockStoreMockRecorder) GetAuthorizedUserCount(arg0, arg1, arg2 interfa
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAuthorizedUserCount", reflect.TypeOf((*MockStore)(nil).GetAuthorizedUserCount), arg0, arg1, arg2)
}
+// GetAuthorizedUsers mocks base method.
+func (m *MockStore) GetAuthorizedUsers(arg0 context.Context, arg1 database.GetUsersParams, arg2 rbac.PreparedAuthorized) ([]database.GetUsersRow, error) {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "GetAuthorizedUsers", arg0, arg1, arg2)
+ ret0, _ := ret[0].([]database.GetUsersRow)
+ ret1, _ := ret[1].(error)
+ return ret0, ret1
+}
+
+// GetAuthorizedUsers indicates an expected call of GetAuthorizedUsers.
+func (mr *MockStoreMockRecorder) GetAuthorizedUsers(arg0, arg1, arg2 interface{}) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAuthorizedUsers", reflect.TypeOf((*MockStore)(nil).GetAuthorizedUsers), arg0, arg1, arg2)
+}
+
// GetAuthorizedWorkspaces mocks base method.
func (m *MockStore) GetAuthorizedWorkspaces(arg0 context.Context, arg1 database.GetWorkspacesParams, arg2 rbac.PreparedAuthorized) ([]database.GetWorkspacesRow, error) {
m.ctrl.T.Helper()
diff --git a/coderd/database/modelmethods.go b/coderd/database/modelmethods.go
index bb7dfdd1bb818..d8b7731c82aab 100644
--- a/coderd/database/modelmethods.go
+++ b/coderd/database/modelmethods.go
@@ -201,7 +201,7 @@ func (m GetOrganizationIDsByMemberIDsRow) RBACObject() rbac.Object {
// TODO: This feels incorrect as we are really returning a list of orgmembers.
// This return type should be refactored to return a list of orgmembers, not this
// special type.
- return rbac.ResourceUser.WithID(m.UserID)
+ return rbac.ResourceUser.WithID(m.UserID).WithOwner(m.UserID.String())
}
func (o Organization) RBACObject() rbac.Object {
@@ -233,7 +233,7 @@ func (f File) RBACObject() rbac.Object {
// If you are trying to get the RBAC object for the UserData, use
// u.UserDataRBACObject() instead.
func (u User) RBACObject() rbac.Object {
- return rbac.ResourceUser.WithID(u.ID)
+ return rbac.ResourceUser.WithID(u.ID).WithOwner(u.ID.String())
}
func (u User) UserDataRBACObject() rbac.Object {
@@ -241,7 +241,7 @@ func (u User) UserDataRBACObject() rbac.Object {
}
func (u GetUsersRow) RBACObject() rbac.Object {
- return rbac.ResourceUser.WithID(u.ID)
+ return rbac.ResourceUser.WithID(u.ID).WithOwner(u.ID.String())
}
func (u GitSSHKey) RBACObject() rbac.Object {
diff --git a/coderd/database/modelqueries.go b/coderd/database/modelqueries.go
index 28a56b825f34e..0738b7153a02b 100644
--- a/coderd/database/modelqueries.go
+++ b/coderd/database/modelqueries.go
@@ -255,11 +255,75 @@ func (q *sqlQuerier) GetAuthorizedWorkspaces(ctx context.Context, arg GetWorkspa
}
type userQuerier interface {
+ GetAuthorizedUsers(ctx context.Context, arg GetUsersParams, prepared rbac.PreparedAuthorized) ([]GetUsersRow, error)
GetAuthorizedUserCount(ctx context.Context, arg GetFilteredUserCountParams, prepared rbac.PreparedAuthorized) (int64, error)
}
+func (q *sqlQuerier) GetAuthorizedUsers(ctx context.Context, arg GetUsersParams, prepared rbac.PreparedAuthorized) ([]GetUsersRow, error) {
+ authorizedFilter, err := prepared.CompileToSQL(ctx, regosql.ConvertConfig{
+ VariableConverter: regosql.UserConverter(),
+ })
+
+ if err != nil {
+ return nil, xerrors.Errorf("compile authorized filter: %w", err)
+ }
+
+ filtered, err := insertAuthorizedFilter(getUsers, fmt.Sprintf(" AND %s", authorizedFilter))
+ if err != nil {
+ return nil, xerrors.Errorf("insert authorized filter: %w", err)
+ }
+
+ query := fmt.Sprintf("-- name: GetAuthorizedUsers :many\n%s", filtered)
+ rows, err := q.db.QueryContext(ctx, query,
+ arg.AfterID,
+ arg.Search,
+ pq.Array(arg.Status),
+ pq.Array(arg.RbacRole),
+ arg.LastSeenBefore,
+ arg.LastSeenAfter,
+ arg.OffsetOpt,
+ arg.LimitOpt,
+ )
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var items []GetUsersRow
+ for rows.Next() {
+ var i GetUsersRow
+ 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.Count,
+ ); err != nil {
+ return nil, err
+ }
+ items = append(items, i)
+ }
+ if err := rows.Close(); err != nil {
+ return nil, err
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
+
func (q *sqlQuerier) GetAuthorizedUserCount(ctx context.Context, arg GetFilteredUserCountParams, prepared rbac.PreparedAuthorized) (int64, error) {
- authorizedFilter, err := prepared.CompileToSQL(ctx, rbac.ConfigWithoutACL())
+ authorizedFilter, err := prepared.CompileToSQL(ctx, regosql.ConvertConfig{
+ VariableConverter: regosql.UserConverter(),
+ })
+
if err != nil {
return -1, xerrors.Errorf("compile authorized filter: %w", err)
}
diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go
index 78537c65ff5df..f29c01269f349 100644
--- a/coderd/database/queries.sql.go
+++ b/coderd/database/queries.sql.go
@@ -5301,6 +5301,9 @@ WHERE
ELSE true
END
-- End of filters
+
+ -- Authorize Filter clause will be injected below in GetAuthorizedUserCount
+ -- @authorize_filter
ORDER BY
-- Deterministic and consistent ordering of all users. This is to ensure consistent pagination.
LOWER(username) ASC OFFSET $7
diff --git a/coderd/database/queries/users.sql b/coderd/database/queries/users.sql
index 75cc85cdf90de..c96cdbd0aa7e1 100644
--- a/coderd/database/queries/users.sql
+++ b/coderd/database/queries/users.sql
@@ -208,6 +208,9 @@ WHERE
ELSE true
END
-- End of filters
+
+ -- Authorize Filter clause will be injected below in GetAuthorizedUserCount
+ -- @authorize_filter
ORDER BY
-- Deterministic and consistent ordering of all users. This is to ensure consistent pagination.
LOWER(username) ASC OFFSET @offset_opt
diff --git a/coderd/rbac/input.json b/coderd/rbac/input.json
index 5e464168ac5ac..71a81ec81de15 100644
--- a/coderd/rbac/input.json
+++ b/coderd/rbac/input.json
@@ -1,12 +1,11 @@
{
- "action": "never-match-action",
+ "action": "read",
"object": {
"id": "9046b041-58ed-47a3-9c3a-de302577875a",
- "owner": "00000000-0000-0000-0000-000000000000",
- "org_owner": "bf7b72bd-a2b1-4ef2-962c-1d698e0483f6",
- "type": "workspace",
+ "owner": "9046b041-58ed-47a3-9c3a-de302577875a",
+ "org_owner": "00000000-0000-0000-0000-000000000000",
+ "type": "user",
"acl_user_list": {
- "f041847d-711b-40da-a89a-ede39f70dc7f": ["create"]
},
"acl_group_list": {}
},
@@ -14,20 +13,21 @@
"id": "10d03e62-7703-4df5-a358-4f76577d4e2f",
"roles": [
{
- "name": "owner",
- "display_name": "Owner",
+ "name": "member",
+ "display_name": "Member",
"site": [
+ ],
+ "org": {},
+ "user": [
{
"negate": false,
- "resource_type": "*",
- "action": "*"
+ "resource_type": "user",
+ "action": "read"
}
- ],
- "org": {},
- "user": []
+ ]
}
],
- "groups": ["b617a647-b5d0-4cbe-9e40-26f89710bf18"],
+ "groups": [],
"scope": {
"name": "Scope_all",
"display_name": "All operations",
diff --git a/coderd/rbac/regosql/compile_test.go b/coderd/rbac/regosql/compile_test.go
index 6c350b7834639..5673b8621c2c7 100644
--- a/coderd/rbac/regosql/compile_test.go
+++ b/coderd/rbac/regosql/compile_test.go
@@ -242,6 +242,26 @@ neq(input.object.owner, "");
p("false")),
VariableConverter: regosql.TemplateConverter(),
},
+ {
+ Name: "UserNoOrgOwner",
+ Queries: []string{
+ `input.object.org_owner != ""`,
+ },
+ ExpectedSQL: p("'' != ''"),
+ VariableConverter: regosql.UserConverter(),
+ },
+ {
+ Name: "UserOwnsSelf",
+ Queries: []string{
+ `"10d03e62-7703-4df5-a358-4f76577d4e2f" = input.object.owner;
+ input.object.owner != "";
+ input.object.org_owner = ""`,
+ },
+ VariableConverter: regosql.UserConverter(),
+ ExpectedSQL: p(
+ p("'10d03e62-7703-4df5-a358-4f76577d4e2f' = id :: text") + " AND " + p("id :: text != ''") + " AND " + p("'' = ''"),
+ ),
+ },
}
for _, tc := range testCases {
diff --git a/coderd/rbac/regosql/configs.go b/coderd/rbac/regosql/configs.go
index 475d317cd53ab..a2f1db4a0cba9 100644
--- a/coderd/rbac/regosql/configs.go
+++ b/coderd/rbac/regosql/configs.go
@@ -22,6 +22,23 @@ func userACLMatcher(m sqltypes.VariableMatcher) sqltypes.VariableMatcher {
return ACLGroupMatcher(m, "user_acl", []string{"input", "object", "acl_user_list"})
}
+func UserConverter() *sqltypes.VariableConverter {
+ matcher := sqltypes.NewVariableConverter().RegisterMatcher(
+ resourceIDMatcher(),
+ // Users are never owned by an organization, so always return the empty string
+ // for the org owner.
+ sqltypes.StringVarMatcher("''", []string{"input", "object", "org_owner"}),
+ // Users are always owned by themselves.
+ sqltypes.StringVarMatcher("id :: text", []string{"input", "object", "owner"}),
+ )
+ matcher.RegisterMatcher(
+ // No ACLs on the user type
+ sqltypes.AlwaysFalse(groupACLMatcher(matcher)),
+ sqltypes.AlwaysFalse(userACLMatcher(matcher)),
+ )
+ return matcher
+}
+
func TemplateConverter() *sqltypes.VariableConverter {
matcher := sqltypes.NewVariableConverter().RegisterMatcher(
resourceIDMatcher(),
diff --git a/coderd/rbac/regosql/sqltypes/always_false.go b/coderd/rbac/regosql/sqltypes/always_false.go
index 93831d844c8b1..da2c1891dae2b 100644
--- a/coderd/rbac/regosql/sqltypes/always_false.go
+++ b/coderd/rbac/regosql/sqltypes/always_false.go
@@ -1,45 +1,58 @@
package sqltypes
import (
+ "strconv"
+
"github.com/open-policy-agent/opa/ast"
)
var (
- _ Node = alwaysFalse{}
- _ VariableMatcher = alwaysFalse{}
+ _ Node = constBoolean{}
+ _ VariableMatcher = constBoolean{}
)
-type alwaysFalse struct {
- Matcher VariableMatcher
+type constBoolean struct {
+ Matcher VariableMatcher
+ constant bool
InnerNode Node
}
// AlwaysFalse overrides the inner node with a constant "false".
func AlwaysFalse(m VariableMatcher) VariableMatcher {
- return alwaysFalse{
- Matcher: m,
+ return constBoolean{
+ Matcher: m,
+ constant: false,
+ }
+}
+
+func AlwaysTrue(m VariableMatcher) VariableMatcher {
+ return constBoolean{
+ Matcher: m,
+ constant: true,
}
}
// AlwaysFalseNode is mainly used for unit testing to make a Node immediately.
func AlwaysFalseNode(n Node) Node {
- return alwaysFalse{
+ return constBoolean{
InnerNode: n,
Matcher: nil,
+ constant: false,
}
}
// UseAs uses a type no one supports to always override with false.
-func (alwaysFalse) UseAs() Node { return alwaysFalse{} }
+func (constBoolean) UseAs() Node { return constBoolean{} }
-func (f alwaysFalse) ConvertVariable(rego ast.Ref) (Node, bool) {
+func (f constBoolean) ConvertVariable(rego ast.Ref) (Node, bool) {
if f.Matcher != nil {
n, ok := f.Matcher.ConvertVariable(rego)
if ok {
- return alwaysFalse{
+ return constBoolean{
Matcher: f.Matcher,
InnerNode: n,
+ constant: f.constant,
}, true
}
}
@@ -47,18 +60,18 @@ func (f alwaysFalse) ConvertVariable(rego ast.Ref) (Node, bool) {
return nil, false
}
-func (alwaysFalse) SQLString(_ *SQLGenerator) string {
- return "false"
+func (c constBoolean) SQLString(_ *SQLGenerator) string {
+ return strconv.FormatBool(c.constant)
}
-func (alwaysFalse) ContainsSQL(_ *SQLGenerator, _ Node) (string, error) {
- return "false", nil
+func (c constBoolean) ContainsSQL(_ *SQLGenerator, _ Node) (string, error) {
+ return strconv.FormatBool(c.constant), nil
}
-func (alwaysFalse) ContainedInSQL(_ *SQLGenerator, _ Node) (string, error) {
- return "false", nil
+func (c constBoolean) ContainedInSQL(_ *SQLGenerator, _ Node) (string, error) {
+ return strconv.FormatBool(c.constant), nil
}
-func (alwaysFalse) EqualsSQLString(_ *SQLGenerator, _ bool, _ Node) (string, error) {
- return "false", nil
+func (c constBoolean) EqualsSQLString(_ *SQLGenerator, _ bool, _ Node) (string, error) {
+ return strconv.FormatBool(c.constant), nil
}
diff --git a/coderd/rbac/roles.go b/coderd/rbac/roles.go
index ee3805b716402..8e6ed66dce546 100644
--- a/coderd/rbac/roles.go
+++ b/coderd/rbac/roles.go
@@ -145,14 +145,18 @@ func ReloadBuiltinRoles(opts *RoleOptions) {
Name: member,
DisplayName: "",
Site: Permissions(map[string][]Action{
- // All users can read all other users and know they exist.
- ResourceUser.Type: {ActionRead},
ResourceRoleAssignment.Type: {ActionRead},
// All users can see the provisioner daemons.
ResourceProvisionerDaemon.Type: {ActionRead},
}),
- Org: map[string][]Permission{},
- User: allPermsExcept(ResourceWorkspaceLocked),
+ Org: map[string][]Permission{},
+ User: append(allPermsExcept(ResourceWorkspaceLocked, ResourceUser),
+ Permissions(map[string][]Action{
+ // Users cannot do create/update/delete on themselves, but they
+ // can read their own details.
+ ResourceUser.Type: {ActionRead},
+ })...,
+ ),
}.withCachedRegoValue()
auditorRole := Role{
@@ -163,6 +167,7 @@ func ReloadBuiltinRoles(opts *RoleOptions) {
// are not in.
ResourceTemplate.Type: {ActionRead},
ResourceAuditLog.Type: {ActionRead},
+ ResourceUser.Type: {ActionRead},
}),
Org: map[string][]Permission{},
User: []Permission{},
@@ -172,6 +177,7 @@ func ReloadBuiltinRoles(opts *RoleOptions) {
Name: templateAdmin,
DisplayName: "Template Admin",
Site: Permissions(map[string][]Action{
+ ResourceUser.Type: {ActionRead},
ResourceTemplate.Type: {ActionCreate, ActionRead, ActionUpdate, ActionDelete},
// CRUD all files, even those they did not upload.
ResourceFile.Type: {ActionCreate, ActionRead, ActionUpdate, ActionDelete},
diff --git a/coderd/rbac/roles_test.go b/coderd/rbac/roles_test.go
index 4c8b90bdfdb67..9d68dce3b92d2 100644
--- a/coderd/rbac/roles_test.go
+++ b/coderd/rbac/roles_test.go
@@ -106,7 +106,7 @@ func TestRolePermissions(t *testing.T) {
{
Name: "MyUser",
Actions: []rbac.Action{rbac.ActionRead},
- Resource: rbac.ResourceUser.WithID(currentUser),
+ Resource: rbac.ResourceUser.WithID(currentUser).WithOwner(currentUser.String()),
AuthorizeMap: map[bool][]authSubject{
true: {owner, memberMe, orgMemberMe, orgAdmin, otherOrgMember, otherOrgAdmin, templateAdmin, userAdmin},
false: {},
diff --git a/coderd/templates.go b/coderd/templates.go
index b2cfb4bf3c229..4841b418a763a 100644
--- a/coderd/templates.go
+++ b/coderd/templates.go
@@ -679,11 +679,19 @@ func (api *API) templateExamples(rw http.ResponseWriter, r *http.Request) {
httpapi.Write(ctx, rw, http.StatusOK, ex)
}
+// getCreatedByNamesByTemplateIDs returns a map of template IDs to the
+// usernames of the users who created them. If the caller does not have
+// permission to view the given creator, then the username will be the empty
+// string.
func getCreatedByNamesByTemplateIDs(ctx context.Context, db database.Store, templates []database.Template) (map[string]string, error) {
creators := make(map[string]string, len(templates))
for _, template := range templates {
creator, err := db.GetUserByID(ctx, template.CreatedBy)
if err != nil {
+ if errors.Is(err, sql.ErrNoRows) || dbauthz.IsNotAuthorizedError(err) {
+ // Users might be omitted if the caller does not have access.
+ continue
+ }
return map[string]string{}, err
}
creators[template.ID.String()] = creator.Username
@@ -713,6 +721,12 @@ func (api *API) convertTemplate(
buildTimeStats := api.metricsCache.TemplateBuildTimeStats(template.ID)
+ // Only include this uuid if the user has permission to view the user.
+ // We know this if the username is not empty.
+ createdBy := uuid.Nil
+ if createdByName != "" {
+ createdBy = template.CreatedBy
+ }
return codersdk.Template{
ID: template.ID,
CreatedAt: template.CreatedAt,
@@ -728,7 +742,7 @@ func (api *API) convertTemplate(
Icon: template.Icon,
DefaultTTLMillis: time.Duration(template.DefaultTTL).Milliseconds(),
MaxTTLMillis: time.Duration(template.MaxTTL).Milliseconds(),
- CreatedByID: template.CreatedBy,
+ CreatedByID: createdBy,
CreatedByName: createdByName,
AllowUserAutostart: template.AllowUserAutostart,
AllowUserAutostop: template.AllowUserAutostop,
diff --git a/coderd/templateversions.go b/coderd/templateversions.go
index 37a7bba98b2be..b68ab888343eb 100644
--- a/coderd/templateversions.go
+++ b/coderd/templateversions.go
@@ -20,6 +20,7 @@ import (
"github.com/coder/coder/coderd/audit"
"github.com/coder/coder/coderd/database"
+ "github.com/coder/coder/coderd/database/dbauthz"
"github.com/coder/coder/coderd/gitauth"
"github.com/coder/coder/coderd/httpapi"
"github.com/coder/coder/coderd/httpmw"
@@ -53,8 +54,9 @@ func (api *API) templateVersion(rw http.ResponseWriter, r *http.Request) {
return
}
+ // User can be the empty user if the caller does not have permission.
user, err := api.Database.GetUserByID(ctx, templateVersion.CreatedBy)
- if err != nil {
+ if err != nil && !dbauthz.IsNotAuthorizedError(err) {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error on fetching user.",
Detail: err.Error(),
@@ -165,7 +167,7 @@ func (api *API) patchTemplateVersion(rw http.ResponseWriter, r *http.Request) {
}
user, err := api.Database.GetUserByID(ctx, templateVersion.CreatedBy)
- if err != nil {
+ if err != nil && !dbauthz.IsNotAuthorizedError(err) {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error on fetching user.",
Detail: err.Error(),
@@ -843,7 +845,7 @@ func (api *API) templateVersionByName(rw http.ResponseWriter, r *http.Request) {
}
user, err := api.Database.GetUserByID(ctx, templateVersion.CreatedBy)
- if err != nil {
+ if err != nil && !dbauthz.IsNotAuthorizedError(err) {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error on fetching user.",
Detail: err.Error(),
@@ -1012,7 +1014,7 @@ func (api *API) previousTemplateVersionByOrganizationTemplateAndName(rw http.Res
}
user, err := api.Database.GetUserByID(ctx, templateVersion.CreatedBy)
- if err != nil {
+ if err != nil && !dbauthz.IsNotAuthorizedError(err) {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error on fetching user.",
Detail: err.Error(),
@@ -1325,7 +1327,7 @@ func (api *API) postTemplateVersionsByOrganization(rw http.ResponseWriter, r *ht
aReq.New = templateVersion
user, err := api.Database.GetUserByID(ctx, templateVersion.CreatedBy)
- if err != nil {
+ if err != nil && !dbauthz.IsNotAuthorizedError(err) {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error on fetching user.",
Detail: err.Error(),
@@ -1404,14 +1406,20 @@ func (api *API) templateVersionLogs(rw http.ResponseWriter, r *http.Request) {
}
func convertTemplateVersion(version database.TemplateVersion, job codersdk.ProvisionerJob, user database.User, warnings []codersdk.TemplateVersionWarning) codersdk.TemplateVersion {
- createdBy := codersdk.User{
- ID: user.ID,
- Username: user.Username,
- Email: user.Email,
- CreatedAt: user.CreatedAt,
- Status: codersdk.UserStatus(user.Status),
- Roles: []codersdk.Role{},
- AvatarURL: user.AvatarURL.String,
+ // Only populate these fields if the user is not nil.
+ // It is usually nil because the caller cannot access the user
+ // resource in question.
+ var createdBy codersdk.User
+ if user.ID != uuid.Nil {
+ createdBy = codersdk.User{
+ ID: user.ID,
+ Username: user.Username,
+ Email: user.Email,
+ CreatedAt: user.CreatedAt,
+ Status: codersdk.UserStatus(user.Status),
+ Roles: []codersdk.Role{},
+ AvatarURL: user.AvatarURL.String,
+ }
}
return codersdk.TemplateVersion{
diff --git a/scripts/dbgen/main.go b/scripts/dbgen/main.go
index 6141980428cef..a162ee3b3807a 100644
--- a/scripts/dbgen/main.go
+++ b/scripts/dbgen/main.go
@@ -32,6 +32,11 @@ func init() {
if err != nil {
panic(err)
}
+ customFuncs, err := readCustomQuerierFunctions()
+ if err != nil {
+ panic(err)
+ }
+ funcs = append(funcs, customFuncs...)
funcByName = map[string]struct{}{}
for _, f := range funcs {
funcByName[f.Name] = struct{}{}
@@ -423,11 +428,25 @@ func readQuerierFunctions() ([]querierFunction, error) {
return nil, err
}
querierPath := filepath.Join(localPath, "..", "..", "..", "coderd", "database", "querier.go")
+ return loadQuerierFunctions(querierPath, "sqlcQuerier")
+}
+
+// readCustomQuerierFunctions reads the functions from coderd/database/modelqueries.go
+func readCustomQuerierFunctions() ([]querierFunction, error) {
+ localPath, err := localFilePath()
+ if err != nil {
+ return nil, err
+ }
+ querierPath := filepath.Join(localPath, "..", "..", "..", "coderd", "database", "modelqueries.go")
+ return loadQuerierFunctions(querierPath, "customQuerier")
+}
- querierData, err := os.ReadFile(querierPath)
+func loadQuerierFunctions(filename string, interfaceName string) ([]querierFunction, error) {
+ querierData, err := os.ReadFile(filename)
if err != nil {
return nil, xerrors.Errorf("read querier: %w", err)
}
+
f, err := decorator.Parse(querierData)
if err != nil {
return nil, err
@@ -447,7 +466,7 @@ func readQuerierFunctions() ([]querierFunction, error) {
}
// This is the name of the interface. If that ever changes,
// this will need to be updated.
- if typeSpec.Name.Name != "sqlcQuerier" {
+ if typeSpec.Name.Name != interfaceName {
continue
}
querier, ok = typeSpec.Type.(*dst.InterfaceType)
@@ -461,7 +480,9 @@ func readQuerierFunctions() ([]querierFunction, error) {
return nil, xerrors.Errorf("querier not found")
}
funcs := []querierFunction{}
- for _, method := range querier.Methods.List {
+ allMethods := interfaceMethods(querier)
+
+ for _, method := range allMethods {
funcType, ok := method.Type.(*dst.FuncType)
if !ok {
continue
@@ -540,3 +561,30 @@ func nameFromSnakeCase(s string) string {
}
return ret
}
+
+// interfaceMethods returns all embedded methods of an interface.
+func interfaceMethods(i *dst.InterfaceType) []*dst.Field {
+ var allMethods []*dst.Field
+ for _, field := range i.Methods.List {
+ switch fieldType := field.Type.(type) {
+ case *dst.FuncType:
+ allMethods = append(allMethods, field)
+ case *dst.InterfaceType:
+ allMethods = append(allMethods, interfaceMethods(fieldType)...)
+ case *dst.Ident:
+ // Embedded interfaces are Idents -> TypeSpec -> InterfaceType
+ // If the embedded interface is not in the parsed file, then
+ // the Obj will be nil.
+ if fieldType.Obj != nil {
+ objDecl, ok := fieldType.Obj.Decl.(*dst.TypeSpec)
+ if ok {
+ isInterface, ok := objDecl.Type.(*dst.InterfaceType)
+ if ok {
+ allMethods = append(allMethods, interfaceMethods(isInterface)...)
+ }
+ }
+ }
+ }
+ }
+ return allMethods
+}
diff --git a/site/src/components/Navbar/Navbar.tsx b/site/src/components/Navbar/Navbar.tsx
index 88042b5bceb8d..0e31f49aa6f99 100644
--- a/site/src/components/Navbar/Navbar.tsx
+++ b/site/src/components/Navbar/Navbar.tsx
@@ -16,6 +16,7 @@ export const Navbar: FC = () => {
const canViewAuditLog =
featureVisibility["audit_log"] && Boolean(permissions.viewAuditLog)
const canViewDeployment = Boolean(permissions.viewDeploymentValues)
+ const canViewUsers = Boolean(permissions.readAllUsers)
const onSignOut = () => authSend("SIGN_OUT")
const proxyContextValue = useProxy()
const dashboard = useDashboard()
@@ -29,6 +30,7 @@ export const Navbar: FC = () => {
onSignOut={onSignOut}
canViewAuditLog={canViewAuditLog}
canViewDeployment={canViewDeployment}
+ canViewUsers={canViewUsers}
proxyContextValue={
dashboard.experiments.includes("moons") ? proxyContextValue : undefined
}
diff --git a/site/src/components/Navbar/NavbarView.test.tsx b/site/src/components/Navbar/NavbarView.test.tsx
index 55f5dd35901c3..63dc3bcb067ea 100644
--- a/site/src/components/Navbar/NavbarView.test.tsx
+++ b/site/src/components/Navbar/NavbarView.test.tsx
@@ -48,6 +48,7 @@ describe("NavbarView", () => {
onSignOut={noop}
canViewAuditLog
canViewDeployment
+ canViewUsers
/>,
)
const workspacesLink = await screen.findByText(navLanguage.workspaces)
@@ -62,6 +63,7 @@ describe("NavbarView", () => {
onSignOut={noop}
canViewAuditLog
canViewDeployment
+ canViewUsers
/>,
)
const templatesLink = await screen.findByText(navLanguage.templates)
@@ -76,6 +78,7 @@ describe("NavbarView", () => {
onSignOut={noop}
canViewAuditLog
canViewDeployment
+ canViewUsers
/>,
)
const userLink = await screen.findByText(navLanguage.users)
@@ -98,6 +101,7 @@ describe("NavbarView", () => {
onSignOut={noop}
canViewAuditLog
canViewDeployment
+ canViewUsers
/>,
)
@@ -115,6 +119,7 @@ describe("NavbarView", () => {
onSignOut={noop}
canViewAuditLog
canViewDeployment
+ canViewUsers
/>,
)
const auditLink = await screen.findByText(navLanguage.audit)
@@ -129,6 +134,7 @@ describe("NavbarView", () => {
onSignOut={noop}
canViewAuditLog={false}
canViewDeployment
+ canViewUsers
/>,
)
const auditLink = screen.queryByText(navLanguage.audit)
@@ -143,6 +149,7 @@ describe("NavbarView", () => {
onSignOut={noop}
canViewAuditLog
canViewDeployment
+ canViewUsers
/>,
)
const auditLink = await screen.findByText(navLanguage.deployment)
@@ -159,6 +166,7 @@ describe("NavbarView", () => {
onSignOut={noop}
canViewAuditLog={false}
canViewDeployment={false}
+ canViewUsers={false}
/>,
)
const auditLink = screen.queryByText(navLanguage.deployment)
diff --git a/site/src/components/Navbar/NavbarView.tsx b/site/src/components/Navbar/NavbarView.tsx
index a2ae924fbb039..513b46f896b3a 100644
--- a/site/src/components/Navbar/NavbarView.tsx
+++ b/site/src/components/Navbar/NavbarView.tsx
@@ -36,6 +36,7 @@ export interface NavbarViewProps {
onSignOut: () => void
canViewAuditLog: boolean
canViewDeployment: boolean
+ canViewUsers: boolean
proxyContextValue?: ProxyContextValue
}
@@ -43,6 +44,7 @@ export const Language = {
workspaces: "Workspaces",
templates: "Templates",
users: "Users",
+ groups: "Groups",
audit: "Audit",
deployment: "Deployment",
}
@@ -52,8 +54,9 @@ const NavItems: React.FC<
className?: string
canViewAuditLog: boolean
canViewDeployment: boolean
+ canViewUsers: boolean
}>
-> = ({ className, canViewAuditLog, canViewDeployment }) => {
+> = ({ className, canViewAuditLog, canViewUsers, canViewDeployment }) => {
const styles = useStyles()
const location = useLocation()
@@ -77,7 +80,7 @@ const NavItems: React.FC<
- {Language.users}
+ {canViewUsers ? Language.users : Language.groups}
{canViewAuditLog && (
@@ -105,6 +108,7 @@ export const NavbarView: FC = ({
onSignOut,
canViewAuditLog,
canViewDeployment,
+ canViewUsers,
proxyContextValue,
}) => {
const styles = useStyles()
@@ -142,6 +146,7 @@ export const NavbarView: FC = ({
@@ -158,6 +163,7 @@ export const NavbarView: FC = ({
className={styles.desktopNavItems}
canViewAuditLog={canViewAuditLog}
canViewDeployment={canViewDeployment}
+ canViewUsers={canViewUsers}
/>
= ({
label={Language.lastUpdateLabel}
value={createDayString(template.updated_at)}
/>
-
+
+
+
)
}