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)} /> - + + + ) }