This is an automated email from the ASF dual-hosted git repository.

dspavlov pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/ignite-teamcity-bot.git


The following commit(s) were added to refs/heads/master by this push:
     new 6df903aa IGNITE-28656 [TCBot] Add bot admin management (#214)
6df903aa is described below

commit 6df903aa7b20936401327776f08a3fef4f3832c7
Author: ignitetcbot <[email protected]>
AuthorDate: Sat May 9 02:04:20 2026 +0300

    IGNITE-28656 [TCBot] Add bot admin management (#214)
    
    IGNITE-28656: add bot admin management, stale credentials handling and 
password rotation support
    ---------
    
    Codex co-authored-by: Dmitriy Pavlov <[email protected]>
---
 README.md                                          |   3 +
 conf/branches.json                                 |   9 +-
 .../ci/tcbot/conf/LocalFilesBasedConfig.java       |  11 +
 .../ignite/ci/web/auth/AuthenticationFilter.java   |  42 +++
 .../apache/ignite/ci/web/model/CredentialsUi.java  |   2 +
 .../apache/ignite/ci/web/model/TcHelperUserUi.java |   3 +
 .../apache/ignite/ci/web/model/UserMenuResult.java |  17 +
 .../ServiceUnauthorizedExceptionMapper.java        |   2 +-
 .../org/apache/ignite/ci/web/rest/login/Login.java |  97 ++++--
 .../ci/web/rest/login/UserAdminRefreshService.java | 198 ++++++++++++
 .../ignite/ci/web/rest/login/UserService.java      |  54 +++-
 .../ci/web/rest/monitoring/MonitoringService.java  |  13 +-
 .../src/main/webapp/css/style-1.5.css              |  53 +++-
 .../src/main/webapp/js/common-1.7.js               |  48 ++-
 ignite-tc-helper-web/src/main/webapp/login.html    |   8 +-
 .../src/main/webapp/monitoring.html                |  88 ++++--
 ignite-tc-helper-web/src/main/webapp/user.html     |  65 +++-
 .../org/apache/ignite/ci/user/LoginAuthTest.java   | 344 ++++++++++++++++++++-
 .../rest/login/UserAdminRefreshServiceTest.java    | 141 +++++++++
 .../ignite/ci/web/rest/login/UserServiceTest.java  | 182 +++++++++++
 .../monitoring/MonitoringServiceSecurityTest.java  |  56 ++++
 .../app/guice/GuiceTcBotApplicationContext.java    |   2 +
 .../ignite/tcbot/app/guice/TcBotWebAppModule.java  |   2 +
 .../apache/ignite/tcbot/common/util/HttpUtil.java  |   6 +-
 .../org/apache/ignite/ci/user/TcHelperUser.java    |  70 ++++-
 .../ignite/tcbot/engine/conf/ITcBotConfig.java     |  11 +
 .../ignite/tcbot/engine/conf/TcBotJsonConfig.java  |  10 +
 .../ignite/tcignited/TcIgnitedCachingProvider.java |  40 ++-
 .../tcservice/TeamcityServiceConnection.java       |   8 +
 .../apache/ignite/tcservice/login/ITcLogin.java    |  14 +
 .../apache/ignite/tcservice/login/TcLoginImpl.java |  23 +-
 .../ignite/tcservice/login/TcLoginResult.java      |  62 ++++
 .../ITcLogin.java => model/user/GroupRef.java}     |  32 +-
 .../model/user/{User.java => Groups.java}          |  46 +--
 .../apache/ignite/tcservice/model/user/User.java   |  33 ++
 35 files changed, 1672 insertions(+), 123 deletions(-)

diff --git a/README.md b/README.md
index 1f7a2f69..babc1ec3 100644
--- a/README.md
+++ b/README.md
@@ -53,6 +53,9 @@ runtime data and local configuration files. The location can 
be changed with the
 
 Examples of configs can be found in [conf](conf) directory. 
 Main config file is [conf/branches.json](conf/branches.json). This file needs 
to be placed to work directory, (under user home by default).
+The running bot reloads `branches.json` lazily: configuration reads are cached 
for up to 3 minutes, so most changes
+become visible without a restart after the cache expires. Restart the bot only 
when you need the change to take effect
+immediately.
 Extra setup is required using security-sensitive information using 
PasswordEncoder. No TeamCity credentials are required because TC bot asks users 
to enter creds.
 
 Minimal local run checklist:
diff --git a/conf/branches.json b/conf/branches.json
index c61c3f84..e7a841c6 100644
--- a/conf/branches.json
+++ b/conf/branches.json
@@ -1,6 +1,9 @@
 {
   // Default server (service) code, internal identification
   "primaryServerCode": "apache",
+  // TeamCity group ids whose members can administer sensitive bot settings.
+  // Apache Ignite test admins are exposed by TeamCity REST as 
key="IGNITE_COMMITER".
+  "botAdminGroups": ["IGNITE_COMMITER"],
   "confidence": 0.995,
   "cleanerConfig": {
     "numOfItemsToDel": 100000,
@@ -161,13 +164,13 @@
       "disableIssueTypes": []
     },
     {
-      "id": "ignite-2.11-nightly",
+      "id": "ignite-2.18-nightly",
       "chains": [
         {
           "serverId": "apache",
           "suiteId": "IgniteTests24Java8_RunAllNightly",
-          "branchForRest": "ignite-2.11",
-          "baseBranchForTc": "ignite-2.10",
+          "branchForRest": "ignite-2.18",
+          "baseBranchForTc": "ignite-2.17",
           "triggerBuild": true,
           //trigger twice per day
           "triggerBuildQuietPeriod": 720  //triggering quiet period in minutes,
diff --git 
a/ignite-tc-helper-web/src/main/java/org/apache/ignite/ci/tcbot/conf/LocalFilesBasedConfig.java
 
b/ignite-tc-helper-web/src/main/java/org/apache/ignite/ci/tcbot/conf/LocalFilesBasedConfig.java
index 1f3ac88e..c6249368 100644
--- 
a/ignite-tc-helper-web/src/main/java/org/apache/ignite/ci/tcbot/conf/LocalFilesBasedConfig.java
+++ 
b/ignite-tc-helper-web/src/main/java/org/apache/ignite/ci/tcbot/conf/LocalFilesBasedConfig.java
@@ -21,6 +21,8 @@ import com.google.common.base.Strings;
 import java.io.File;
 import java.io.FileReader;
 import java.io.IOException;
+import java.util.Collection;
+import java.util.Collections;
 import java.util.Objects;
 import java.util.Properties;
 
@@ -133,6 +135,15 @@ public class LocalFilesBasedConfig implements ITcBotConfig 
{
         return alwaysFailedTestDetection != null && alwaysFailedTestDetection;
     }
 
+    /** {@inheritDoc} */
+    @Override public Collection<String> botAdminGroups() {
+        Collection<String> botAdminGroups = getConfig().botAdminGroups();
+
+        return botAdminGroups == null || botAdminGroups.isEmpty()
+            ? Collections.singleton(ITcBotConfig.DEFAULT_BOT_ADMIN_GROUP)
+            : botAdminGroups;
+    }
+
     @Override
     public ITrackedBranchesConfig getTrackedBranches() {
         return getConfig();
diff --git 
a/ignite-tc-helper-web/src/main/java/org/apache/ignite/ci/web/auth/AuthenticationFilter.java
 
b/ignite-tc-helper-web/src/main/java/org/apache/ignite/ci/web/auth/AuthenticationFilter.java
index f92b81d7..3c6af84e 100644
--- 
a/ignite-tc-helper-web/src/main/java/org/apache/ignite/ci/web/auth/AuthenticationFilter.java
+++ 
b/ignite-tc-helper-web/src/main/java/org/apache/ignite/ci/web/auth/AuthenticationFilter.java
@@ -21,10 +21,13 @@ import com.google.common.base.Throwables;
 import org.apache.ignite.tcbot.common.application.TcBotApplicationContext;
 import java.lang.reflect.Method;
 import java.util.Arrays;
+import java.util.HashSet;
 import java.util.List;
+import java.util.Set;
 import java.util.StringTokenizer;
 import javax.annotation.security.DenyAll;
 import javax.annotation.security.PermitAll;
+import javax.annotation.security.RolesAllowed;
 import javax.crypto.BadPaddingException;
 import javax.servlet.ServletContext;
 import javax.ws.rs.container.ContainerRequestContext;
@@ -53,6 +56,9 @@ import org.slf4j.LoggerFactory;
  */
 @Provider
 public class AuthenticationFilter implements ContainerRequestFilter {
+    /** Admin role name for {@link RolesAllowed}. */
+    public static final String ADMIN_ROLE = "ADMIN";
+
     /** Logger. */
     private static final Logger logger = 
LoggerFactory.getLogger(AuthenticationFilter.class);
 
@@ -128,9 +134,41 @@ public class AuthenticationFilter implements 
ContainerRequestFilter {
 
         if (!authenticate(reqCtx, tokFull, users)) {
             reqCtx.abortWith(rspUnathorized());
+
+            return;
+        }
+
+        RolesAllowed rolesAnnotation = rolesAllowed(mtd);
+
+        //Verify user access
+        if (rolesAnnotation != null) {
+            Set<String> rolesSet = new 
HashSet<String>(Arrays.asList(rolesAnnotation.value()));
+            ITcBotUserCreds creds = 
(ITcBotUserCreds)reqCtx.getProperty(ITcBotUserCreds._KEY);
+            TcHelperUser user = users.getUser(creds.getPrincipalId());
+
+            if (!isUserAllowed(user, rolesSet)) {
+                reqCtx.abortWith(rspForbidden());
+
+                return;
+            }
         }
     }
 
+    /**
+     * @param mtd Resource method.
+     */
+    private RolesAllowed rolesAllowed(Method mtd) {
+        if (mtd.isAnnotationPresent(RolesAllowed.class))
+            return mtd.getAnnotation(RolesAllowed.class);
+
+        Class<?> resourceClass = resourceInfo.getResourceClass();
+
+        if (resourceClass != null && 
resourceClass.isAnnotationPresent(RolesAllowed.class))
+            return resourceClass.getAnnotation(RolesAllowed.class);
+
+        return null;
+    }
+
     public boolean authenticate(ContainerRequestContext reqCtx,
         String tokFull,
         IUserStorage users) {
@@ -237,4 +275,8 @@ public class AuthenticationFilter implements 
ContainerRequestFilter {
             }
         };
     }
+
+    private boolean isUserAllowed(TcHelperUser user, Set<String> rolesSet) {
+        return user != null && rolesSet.contains(ADMIN_ROLE) && user.isAdmin();
+    }
 }
diff --git 
a/ignite-tc-helper-web/src/main/java/org/apache/ignite/ci/web/model/CredentialsUi.java
 
b/ignite-tc-helper-web/src/main/java/org/apache/ignite/ci/web/model/CredentialsUi.java
index 481d693d..bff393d6 100644
--- 
a/ignite-tc-helper-web/src/main/java/org/apache/ignite/ci/web/model/CredentialsUi.java
+++ 
b/ignite-tc-helper-web/src/main/java/org/apache/ignite/ci/web/model/CredentialsUi.java
@@ -21,5 +21,7 @@ public class CredentialsUi {
     public String serviceId;
     public String serviceLogin;
     public String servicePassword;
+    public boolean stale;
+    public String staleReason;
 
 }
diff --git 
a/ignite-tc-helper-web/src/main/java/org/apache/ignite/ci/web/model/TcHelperUserUi.java
 
b/ignite-tc-helper-web/src/main/java/org/apache/ignite/ci/web/model/TcHelperUserUi.java
index b28954df..1efdd7a5 100644
--- 
a/ignite-tc-helper-web/src/main/java/org/apache/ignite/ci/web/model/TcHelperUserUi.java
+++ 
b/ignite-tc-helper-web/src/main/java/org/apache/ignite/ci/web/model/TcHelperUserUi.java
@@ -34,10 +34,13 @@ public class TcHelperUserUi {
 
     public String email;
 
+    public boolean admin;
+
     public TcHelperUserUi(TcHelperUser user, List<String> allTrackedBranches) {
         login = user.username;
         fullName = user.fullName;
         email = user.email;
+        admin = user.isAdmin();
         allTrackedBranches.forEach(
             branchId -> subscribedAllToBranchFailures.put(branchId, 
user.isSubscribedToBranch(branchId))
         );
diff --git 
a/ignite-tc-helper-web/src/main/java/org/apache/ignite/ci/web/model/UserMenuResult.java
 
b/ignite-tc-helper-web/src/main/java/org/apache/ignite/ci/web/model/UserMenuResult.java
index 325b8eb8..4b5f18f7 100644
--- 
a/ignite-tc-helper-web/src/main/java/org/apache/ignite/ci/web/model/UserMenuResult.java
+++ 
b/ignite-tc-helper-web/src/main/java/org/apache/ignite/ci/web/model/UserMenuResult.java
@@ -17,13 +17,30 @@
 
 package org.apache.ignite.ci.web.model;
 
+import java.util.ArrayList;
+import java.util.List;
+
 public class UserMenuResult extends SimpleResult {
     public String username;
     public boolean authorizedState;
+    public boolean admin;
+    public List<User> users = new ArrayList<>();
 
     public UserMenuResult(String result) {
         super(result);
 
         this.username = result;
     }
+
+    public static class User {
+        public String username;
+        public String displayName;
+        public boolean admin;
+
+        public User(String username, String displayName, boolean admin) {
+            this.username = username;
+            this.displayName = displayName;
+            this.admin = admin;
+        }
+    }
 }
diff --git 
a/ignite-tc-helper-web/src/main/java/org/apache/ignite/ci/web/rest/exception/ServiceUnauthorizedExceptionMapper.java
 
b/ignite-tc-helper-web/src/main/java/org/apache/ignite/ci/web/rest/exception/ServiceUnauthorizedExceptionMapper.java
index 975732e0..14b353bc 100644
--- 
a/ignite-tc-helper-web/src/main/java/org/apache/ignite/ci/web/rest/exception/ServiceUnauthorizedExceptionMapper.java
+++ 
b/ignite-tc-helper-web/src/main/java/org/apache/ignite/ci/web/rest/exception/ServiceUnauthorizedExceptionMapper.java
@@ -34,7 +34,7 @@ public class ServiceUnauthorizedExceptionMapper
 
     @Override
     public Response toResponse(ServiceUnauthorizedException exception) {
-        return Response.status(424).entity(exception.getMessage())
+        return 
Response.status(Response.Status.UNAUTHORIZED).entity(exception.getMessage())
                 .type("text/plain").build();
     }
 }
diff --git 
a/ignite-tc-helper-web/src/main/java/org/apache/ignite/ci/web/rest/login/Login.java
 
b/ignite-tc-helper-web/src/main/java/org/apache/ignite/ci/web/rest/login/Login.java
index 52dfd742..e82a1f51 100644
--- 
a/ignite-tc-helper-web/src/main/java/org/apache/ignite/ci/web/rest/login/Login.java
+++ 
b/ignite-tc-helper-web/src/main/java/org/apache/ignite/ci/web/rest/login/Login.java
@@ -24,6 +24,7 @@ import org.apache.ignite.tcbot.engine.user.IUserStorage;
 import org.apache.ignite.tcservice.model.user.User;
 import org.apache.ignite.tcignited.ITeamcityIgnitedProvider;
 import org.apache.ignite.tcservice.login.ITcLogin;
+import org.apache.ignite.tcservice.login.TcLoginResult;
 import org.apache.ignite.ci.user.TcHelperUser;
 import org.apache.ignite.tcbot.common.util.Base64Util;
 import org.apache.ignite.tcbot.common.util.CryptUtil;
@@ -39,6 +40,7 @@ import javax.ws.rs.core.Context;
 import java.security.SecureRandom;
 import java.util.Arrays;
 import java.util.Collection;
+import java.util.Collections;
 
 @Path("login")
 @Produces("application/json")
@@ -79,7 +81,7 @@ public class Login {
         String primarySrvCode = cfg.primaryServerCode();
 
         try {
-            return doLogin(username, pwd, users, primarySrvCode, 
cfg.getServerIds(), tcLogin);
+            return doLogin(username, pwd, users, primarySrvCode, 
cfg.getServerIds(), tcLogin, cfg.botAdminGroups());
         } catch (Exception e) {
             e.printStackTrace();
             throw e;
@@ -92,6 +94,17 @@ public class Login {
                                  String primarySrvId,
                                  Collection<String> srvIds,
                                  ITcLogin tcLogin) {
+        return doLogin(username, pwd, users, primarySrvId, srvIds, tcLogin,
+            Collections.singleton(ITcBotConfig.DEFAULT_BOT_ADMIN_GROUP));
+    }
+
+    public LoginResponse doLogin(@FormParam("uname") String username,
+                                 @FormParam("psw") String pwd,
+        IUserStorage users,
+                                 String primarySrvId,
+                                 Collection<String> srvIds,
+                                 ITcLogin tcLogin,
+                                 Collection<String> botAdminGroups) {
         SecureRandom random = new SecureRandom();
         byte[] tokBytes = random.generateSeed(TOKEN_LEN);
         String tok = Base64Util.encodeBytesToString(tokBytes);
@@ -114,7 +127,8 @@ public class Login {
         byte[] userKeyCandidateKcv = CryptUtil.aesKcv(userKeyCandidate);
 
 
-        final User tcUser = tcLogin.checkServiceUserAndPassword(primarySrvId, 
username, pwd);
+        final TcLoginResult loginResult = 
tcLogin.checkServiceUserAndPasswordResult(primarySrvId, username, pwd);
+        final User tcUser = loginResult.user();
 
         if (user.userKeyKcv == null) {
             if (tcUser == null) {
@@ -123,32 +137,43 @@ public class Login {
 
                 return loginRes;
             }
-            user.userKeyKcv = userKeyCandidateKcv;
-
-            user.email = tcUser.email;
-            user.fullName = tcUser.name;
 
-            
user.getOrCreateCreds(primarySrvId).setLogin(username).setPassword(pwd, 
userKeyCandidate);
+            updateUserKeyAndCredentials(user, userKeyCandidateKcv, 
userKeyCandidate, primarySrvId, srvIds, username,
+                pwd, tcUser, tcLogin);
+        } else {
+            if (Arrays.equals(userKeyCandidateKcv, user.userKeyKcv)) {
+                if (loginResult.isUnauthorized()) {
+                    loginRes.errorMessage =
+                        "Service " + primarySrvId + " rejected 
credentials/user not found";
 
-            user.enrichUserData(tcUser);
+                    return loginRes;
+                }
+            }
+            else {
+                if (tcUser == null) {
+                    loginRes.errorMessage = loginResult.isUnauthorized()
+                        ? "Service " + primarySrvId + " rejected 
credentials/user not found"
+                        : "Password does not match stored bot credentials";
 
-            for (String addSrvId : srvIds) {
-                if (!addSrvId.equals(primarySrvId)) {
-                    final User tcAddUser = 
tcLogin.checkServiceUserAndPassword(addSrvId, username, pwd);
+                    return loginRes;
+                }
 
-                    if (tcAddUser != null) {
-                        
user.getOrCreateCreds(addSrvId).setLogin(username).setPassword(pwd, 
userKeyCandidate);
+                user.markCredentialsStale("Replaced after successful TeamCity 
login with a new password");
 
-                        user.enrichUserData(tcAddUser);
-                    }
-                }
+                updateUserKeyAndCredentials(user, userKeyCandidateKcv, 
userKeyCandidate, primarySrvId, srvIds,
+                    username, pwd, tcUser, tcLogin);
             }
-
-            users.putUser(username, user);
-        } else {
-            if (!Arrays.equals(userKeyCandidateKcv, user.userKeyKcv))
-                return loginRes; //password validation failed
         }
+
+        if (tcUser != null)
+            user.enrichUserData(tcUser);
+
+        if (tcUser != null)
+            user.updateAdmin(tcUser.belongsToAnyGroup(botAdminGroups), 
System.currentTimeMillis());
+
+        users.putUser(username, user);
+
+        //todo may be enrich user data here as well.
         userSes.userKeyUnderToken = CryptUtil.aesEncrypt(tokBytes, 
userKeyCandidate);
 
         users.putSession(sessId, userSes);
@@ -158,6 +183,36 @@ public class Login {
         return loginRes;
     }
 
+    private void updateUserKeyAndCredentials(
+        TcHelperUser user,
+        byte[] userKeyCandidateKcv,
+        byte[] userKeyCandidate,
+        String primarySrvId,
+        Collection<String> srvIds,
+        String username,
+        String pwd,
+        User tcUser,
+        ITcLogin tcLogin
+    ) {
+        user.userKeyKcv = userKeyCandidateKcv;
+
+        
user.getOrCreateCreds(primarySrvId).setLogin(username).setPassword(pwd, 
userKeyCandidate);
+
+        user.enrichUserData(tcUser);
+
+        for (String addSrvId : srvIds) {
+            if (!addSrvId.equals(primarySrvId)) {
+                final User tcAddUser = 
tcLogin.checkServiceUserAndPasswordResult(addSrvId, username, pwd).user();
+
+                if (tcAddUser != null) {
+                    
user.getOrCreateCreds(addSrvId).setLogin(username).setPassword(pwd, 
userKeyCandidate);
+
+                    user.enrichUserData(tcAddUser);
+                }
+            }
+        }
+    }
+
     private TcHelperUser getOrCreateUser(@FormParam("uname") String username,
         IUserStorage users,
         SecureRandom random) {
diff --git 
a/ignite-tc-helper-web/src/main/java/org/apache/ignite/ci/web/rest/login/UserAdminRefreshService.java
 
b/ignite-tc-helper-web/src/main/java/org/apache/ignite/ci/web/rest/login/UserAdminRefreshService.java
new file mode 100644
index 00000000..5f80f445
--- /dev/null
+++ 
b/ignite-tc-helper-web/src/main/java/org/apache/ignite/ci/web/rest/login/UserAdminRefreshService.java
@@ -0,0 +1,198 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.ignite.ci.web.rest.login;
+
+import java.io.FileNotFoundException;
+import java.io.UncheckedIOException;
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.stream.Collectors;
+import javax.inject.Inject;
+import javax.inject.Provider;
+import org.apache.ignite.ci.tcbot.ITcBotBgAuth;
+import org.apache.ignite.ci.user.ITcBotUserCreds;
+import org.apache.ignite.ci.user.TcHelperUser;
+import org.apache.ignite.tcbot.common.exeption.ServiceUnauthorizedException;
+import org.apache.ignite.tcbot.common.exeption.ServiceUnavailableException;
+import org.apache.ignite.tcbot.common.interceptor.MonitoredTask;
+import org.apache.ignite.tcbot.engine.conf.ITcBotConfig;
+import org.apache.ignite.tcbot.engine.user.IUserStorage;
+import org.apache.ignite.tcbot.persistence.scheduler.IScheduler;
+import org.apache.ignite.tcservice.TeamcityServiceConnection;
+import org.apache.ignite.tcservice.model.user.User;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Periodically refreshes cached bot admin flags using background TeamCity 
credentials.
+ */
+public class UserAdminRefreshService {
+    /** Admin status is allowed to be stale while TeamCity is temporarily 
unavailable. */
+    public static final long ADMIN_STATUS_MAX_AGE_MS = 
TimeUnit.DAYS.toMillis(1);
+
+    /** Background refresh period. */
+    private static final long REFRESH_PERIOD_HOURS = 1;
+
+    /** Scheduler task name. */
+    private static final String TASK_NAME = "userAdminRefresh";
+
+    /** Logger. */
+    private static final Logger logger = 
LoggerFactory.getLogger(UserAdminRefreshService.class);
+
+    @Inject private IScheduler scheduler;
+
+    @Inject private IUserStorage users;
+
+    @Inject private ITcBotConfig cfg;
+
+    @Inject private ITcBotBgAuth bgAuth;
+
+    @Inject private Provider<TeamcityServiceConnection> tcFactory;
+
+    /** Start guard. */
+    private final AtomicBoolean started = new AtomicBoolean();
+
+    /**
+     * Starts periodic admin refresh.
+     */
+    public void start() {
+        if (!started.compareAndSet(false, true))
+            return;
+
+        scheduler.invokeLater(this::scheduleRefresh, 1, TimeUnit.MINUTES);
+    }
+
+    /**
+     * Schedules next refresh.
+     */
+    private void scheduleRefresh() {
+        scheduler.sheduleNamed(TASK_NAME, this::refreshAndReschedule, 
REFRESH_PERIOD_HOURS, TimeUnit.HOURS);
+    }
+
+    /**
+     * Refreshes and schedules next attempt.
+     */
+    private void refreshAndReschedule() {
+        try {
+            refreshStaleAdmins();
+        }
+        catch (Exception e) {
+            logger.warn("Failed to refresh admin flags: " + e.getMessage(), e);
+        }
+        finally {
+            scheduler.invokeLater(this::scheduleRefresh, REFRESH_PERIOD_HOURS, 
TimeUnit.HOURS);
+        }
+    }
+
+    /**
+     * Refreshes stale admin flags.
+     */
+    @MonitoredTask(name = "Refresh Bot Admin Flags")
+    public String refreshStaleAdmins() {
+        ITcBotUserCreds creds = bgAuth.getServerAuthorizerCreds();
+        String srvId = cfg.primaryServerCode();
+
+        if (creds == null || !creds.hasAccess(srvId))
+            return "Skipped: no background TeamCity credentials for " + srvId;
+
+        long nowTs = System.currentTimeMillis();
+        List<TcHelperUser> staleUsers = users.allUsers()
+            .filter(user -> user != null && user.username != null)
+            .filter(user -> user.isAdminStatusStale(nowTs, 
ADMIN_STATUS_MAX_AGE_MS))
+            .collect(Collectors.toList());
+
+        if (staleUsers.isEmpty())
+            return "No stale admin flags";
+
+        TeamcityServiceConnection tcConn = tcFactory.get();
+        tcConn.init(srvId);
+        tcConn.setAuthData(creds.getUser(srvId), creds.getPassword(srvId));
+
+        int refreshed = 0;
+        int invalidated = 0;
+        int notFound = 0;
+
+        for (TcHelperUser user : staleUsers) {
+            try {
+                User tcUser = tcConn.getUserByUsername(user.username);
+
+                boolean admin = tcUser != null && 
tcUser.belongsToAnyGroup(cfg.botAdminGroups());
+
+                user.updateAdmin(admin, nowTs);
+
+                if (tcUser != null)
+                    user.enrichUserData(tcUser);
+
+                users.putUser(user.username, user);
+
+                refreshed++;
+
+                if (!admin)
+                    invalidated++;
+            }
+            catch (RuntimeException e) {
+                if (isNotFound(e)) {
+                    user.updateAdmin(false, nowTs);
+                    users.putUser(user.username, user);
+
+                    refreshed++;
+                    invalidated++;
+                    notFound++;
+
+                    continue;
+                }
+
+                if (isTeamCityUnavailable(e))
+                    return "Skipped: TeamCity is unavailable, refreshed=" + 
refreshed;
+
+                throw e;
+            }
+        }
+
+        return "Refreshed " + refreshed + " stale admin flag(s), invalidated " 
+ invalidated +
+            ", not found " + notFound;
+    }
+
+    /**
+     * @param e Exception.
+     */
+    private boolean isNotFound(Throwable e) {
+        for (Throwable th = e; th != null; th = th.getCause()) {
+            if (th instanceof FileNotFoundException)
+                return true;
+        }
+
+        return false;
+    }
+
+    /**
+     * @param e Exception.
+     */
+    private boolean isTeamCityUnavailable(Throwable e) {
+        for (Throwable th = e; th != null; th = th.getCause()) {
+            if (th instanceof ServiceUnavailableException || th instanceof 
ServiceUnauthorizedException)
+                return true;
+
+            if (th instanceof UncheckedIOException && !isNotFound(th))
+                return true;
+        }
+
+        return false;
+    }
+}
diff --git 
a/ignite-tc-helper-web/src/main/java/org/apache/ignite/ci/web/rest/login/UserService.java
 
b/ignite-tc-helper-web/src/main/java/org/apache/ignite/ci/web/rest/login/UserService.java
index 03f876e9..e3a0e99a 100644
--- 
a/ignite-tc-helper-web/src/main/java/org/apache/ignite/ci/web/rest/login/UserService.java
+++ 
b/ignite-tc-helper-web/src/main/java/org/apache/ignite/ci/web/rest/login/UserService.java
@@ -19,11 +19,15 @@ package org.apache.ignite.ci.web.rest.login;
 
 import com.google.common.base.Preconditions;
 import com.google.common.base.Strings;
+import java.util.Comparator;
+import java.util.Objects;
 import org.apache.ignite.tcbot.common.application.TcBotApplicationContext;
 import javax.servlet.ServletContext;
 import javax.servlet.http.HttpServletRequest;
+import javax.ws.rs.ForbiddenException;
 import javax.ws.rs.FormParam;
 import javax.ws.rs.GET;
+import javax.ws.rs.NotFoundException;
 import javax.ws.rs.POST;
 import javax.ws.rs.Path;
 import javax.ws.rs.Produces;
@@ -40,6 +44,7 @@ import 
org.apache.ignite.ci.tcbot.visa.TcBotTriggerAndSignOffService;
 import org.apache.ignite.tcbot.engine.conf.ITrackedBranch;
 import org.apache.ignite.tcservice.model.user.User;
 import org.apache.ignite.tcservice.login.ITcLogin;
+import org.apache.ignite.tcservice.login.TcLoginResult;
 import org.apache.ignite.ci.user.ITcBotUserCreds;
 import org.apache.ignite.ci.user.TcHelperUser;
 import org.apache.ignite.ci.web.CtxListener;
@@ -87,6 +92,15 @@ public class UserService {
         UserMenuResult res = new UserMenuResult(user.getDisplayName());
 
         res.authorizedState = issueDetector.isAuthorized();
+        res.admin = user.isAdmin();
+
+        if (user.isAdmin()) {
+            users.allUsers()
+                .filter(next -> !Objects.equals(user.username, next.username))
+                .sorted(Comparator.comparing(TcHelperUser::getDisplayName, 
String.CASE_INSENSITIVE_ORDER))
+                .map(next -> new UserMenuResult.User(next.username, 
next.getDisplayName(), next.isAdmin()))
+                .forEach(res.users::add);
+        }
 
         return res;
     }
@@ -123,7 +137,14 @@ public class UserService {
         ITcBotConfig cfg = appCtx.getInstance(ITcBotConfig.class);
 
         IUserStorage users = appCtx.getInstance(IUserStorage.class);
+        final TcHelperUser currUser = users.getUser(currUserLogin);
+        ensureCanAccessUser(currUser, currUserLogin, login);
+
         final TcHelperUser user = users.getUser(login);
+        if (user == null)
+            throw new NotFoundException("User not found: " + login);
+
+        //todo can filter accessibliity
         final TcHelperUserUi tcHelperUserUi = new TcHelperUserUi(user,
                 cfg.getTrackedBranches().branchesStream()
                         .map(ITrackedBranch::name)
@@ -138,6 +159,8 @@ public class UserService {
 
             final byte[] encPass = next.getPasswordUnderUserKey();
             credsUi.servicePassword = encPass != null && encPass.length > 0 ? 
"*******" : "";
+            credsUi.stale = next.isStale();
+            credsUi.staleReason = next.getStaleReason();
 
             tcHelperUserUi.data.add(credsUi);
         }
@@ -152,7 +175,12 @@ public class UserService {
         final String login = Strings.isNullOrEmpty(loginParm) ? currUserLogin 
: loginParm;
 
         final IUserStorage users = 
CtxListener.getApplicationContext(ctx).getInstance(IUserStorage.class);
+        final TcHelperUser currUser = users.getUser(currUserLogin);
+        ensureCanAccessUser(currUser, currUserLogin, login);
+
         final TcHelperUser user = users.getUser(login);
+        if (user == null)
+            throw new NotFoundException("User not found: " + login);
 
         user.resetCredentials();
 
@@ -177,19 +205,18 @@ public class UserService {
 
         final IUserStorage users = appCtx.getInstance(IUserStorage.class);
         final TcHelperUser user = users.getUser(currUserLogin);
-        final User tcAddUser = tcLogin.checkServiceUserAndPassword(svcId, 
svcLogin, svcPwd);
+        final TcLoginResult loginResult = 
tcLogin.checkServiceUserAndPasswordResult(svcId, svcLogin, svcPwd);
+        final User tcAddUser = loginResult.user();
 
         if (tcAddUser == null)
             return new SimpleResult("Service rejected credentials/user not 
found");
 
-        final TcHelperUser.Credentials creds = new 
TcHelperUser.Credentials(svcId, svcLogin);
+        final TcHelperUser.Credentials creds = 
user.getOrCreateCreds(svcId).setLogin(svcLogin);
 
         creds.setPassword(svcPwd, prov.getUserKey());
 
         user.enrichUserData(tcAddUser);
 
-        user.getCredentialsList().add(creds);
-
         users.putUser(currUserLogin, user);
 
         return new SimpleResult("");
@@ -202,10 +229,16 @@ public class UserService {
         @Nullable @FormParam("fullName") final String fullName,
         Form form) {
 
-        final String login = ITcBotUserCreds.get(req).getPrincipalId();
+        final String currUserLogin = ITcBotUserCreds.get(req).getPrincipalId();
+        final String login = Strings.isNullOrEmpty(loginParm) ? currUserLogin 
: loginParm;
 
         final IUserStorage users = 
CtxListener.getApplicationContext(ctx).getInstance(IUserStorage.class);
+        final TcHelperUser currUser = users.getUser(currUserLogin);
+        ensureCanAccessUser(currUser, currUserLogin, login);
+
         final TcHelperUser user = users.getUser(login);
+        if (user == null)
+            throw new NotFoundException("User not found: " + login);
 
         user.resetNotifications();
         form.asMap().forEach((k, v) -> {
@@ -222,9 +255,18 @@ public class UserService {
         user.fullName = fullName;
         user.email = email;
 
-        users.putUser(user.username, user);
+        users.putUser(login, user);
 
         return new SimpleResult("");
     }
 
+    /**
+     * @param currUser Current user.
+     * @param currUserLogin Current user login.
+     * @param requestedLogin Requested user login.
+     */
+    private void ensureCanAccessUser(TcHelperUser currUser, String 
currUserLogin, String requestedLogin) {
+        if (!Objects.equals(currUserLogin, requestedLogin) && (currUser == 
null || !currUser.isAdmin()))
+            throw new ForbiddenException("Only bot admin can access other 
users");
+    }
 }
diff --git 
a/ignite-tc-helper-web/src/main/java/org/apache/ignite/ci/web/rest/monitoring/MonitoringService.java
 
b/ignite-tc-helper-web/src/main/java/org/apache/ignite/ci/web/rest/monitoring/MonitoringService.java
index 6dd213c2..60342226 100644
--- 
a/ignite-tc-helper-web/src/main/java/org/apache/ignite/ci/web/rest/monitoring/MonitoringService.java
+++ 
b/ignite-tc-helper-web/src/main/java/org/apache/ignite/ci/web/rest/monitoring/MonitoringService.java
@@ -33,6 +33,7 @@ import org.apache.ignite.IgniteCache;
 import org.apache.ignite.cache.CacheMetrics;
 import org.apache.ignite.cache.affinity.Affinity;
 import org.apache.ignite.ci.web.CtxListener;
+import org.apache.ignite.ci.web.auth.AuthenticationFilter;
 import org.apache.ignite.ci.web.model.SimpleResult;
 import org.apache.ignite.tcbot.common.conf.TcBotWorkDir;
 import org.apache.ignite.tcbot.common.monitoring.MonitoredTasks;
@@ -45,7 +46,7 @@ import org.apache.ignite.tcbot.notify.IEmailSender;
 import org.apache.ignite.tcbot.notify.ISendEmailConfig;
 import org.apache.ignite.tcbot.notify.ISlackSender;
 
-import javax.annotation.security.PermitAll;
+import javax.annotation.security.RolesAllowed;
 import javax.servlet.ServletContext;
 import javax.ws.rs.*;
 import javax.ws.rs.core.Context;
@@ -101,7 +102,6 @@ public class MonitoringService {
     private ServletContext ctx;
 
     @GET
-    @PermitAll
     @Path("tasks")
     public List<TaskResult> getTaskMonitoring() {
         MonitoredTasks instance = instance(MonitoredTasks.class);
@@ -122,6 +122,7 @@ public class MonitoringService {
     }
 
     @GET
+    @RolesAllowed(AuthenticationFilter.ADMIN_ROLE)
     @Path("appLogSummaryLink")
     public AppLogSummaryLink getAppLogSummaryLink() {
         MonitoredTasks instance = instance(MonitoredTasks.class);
@@ -134,6 +135,7 @@ public class MonitoringService {
     }
 
     @GET
+    @RolesAllowed(AuthenticationFilter.ADMIN_ROLE)
     @Path("taskLog")
     public List<AppLogEntry> getTaskLog(@QueryParam("startTs") long startTs, 
@QueryParam("endTs") long endTs) {
         if (startTs <= 0)
@@ -372,7 +374,6 @@ public class MonitoringService {
 
 
     @GET
-    @PermitAll
     @Path("profiling")
     public List<HotSpot> getHotMethods() {
         ProfilingMonitor instance = instance(ProfilingMonitor.class);
@@ -394,6 +395,7 @@ public class MonitoringService {
     }
 
     @POST
+    @RolesAllowed(AuthenticationFilter.ADMIN_ROLE)
     @Path("resetProfiling")
     public SimpleResult resetProfiling() {
         ProfilingMonitor instance = instance(ProfilingMonitor.class);
@@ -404,7 +406,7 @@ public class MonitoringService {
     }
 
     @POST
-    @PermitAll
+    @RolesAllowed(AuthenticationFilter.ADMIN_ROLE)
     @Path("testSlackNotification")
     public SimpleResult testSlackNotification() {
         ISlackSender slackSender = instance(ISlackSender.class);
@@ -426,6 +428,7 @@ public class MonitoringService {
     }
 
     @POST
+    @RolesAllowed(AuthenticationFilter.ADMIN_ROLE)
     @Path("testEmailNotification")
     public SimpleResult testEmailNotification(@FormParam("address") String 
address) {
         IEmailSender emailSender = instance(IEmailSender.class);
@@ -448,7 +451,6 @@ public class MonitoringService {
 
 
     @GET
-    @PermitAll
     @Path("cacheMetrics")
     public List<CacheMetricsUi> getCacheStat() {
         Ignite ignite = instance(Ignite.class);
@@ -493,6 +495,7 @@ public class MonitoringService {
     }
 
     @POST
+    @RolesAllowed(AuthenticationFilter.ADMIN_ROLE)
     @Path("resetRequests")
     public SimpleResult resetRequestStats() {
         RestRequestTimingStorage.reset();
diff --git a/ignite-tc-helper-web/src/main/webapp/css/style-1.5.css 
b/ignite-tc-helper-web/src/main/webapp/css/style-1.5.css
index 1ea10d57..66a622b4 100644
--- a/ignite-tc-helper-web/src/main/webapp/css/style-1.5.css
+++ b/ignite-tc-helper-web/src/main/webapp/css/style-1.5.css
@@ -106,6 +106,57 @@ table.stat tr:nth-child(odd) { background-color: #fafaff; }
     float: right;
 }
 
+.navbar .dropdown {
+    float: left;
+    overflow: hidden;
+}
+
+.navbar .dropdown .dropbtn {
+    display: block;
+    padding: 7px 8px;
+    border: none;
+    outline: none;
+    background: inherit;
+    color: inherit;
+    box-shadow: none;
+    height: auto;
+    margin: 0;
+    font-weight: 500;
+}
+
+.navbar .dropdown:hover .dropbtn {
+    color: white;
+    background: #125CAA;
+}
+
+.navbar .dropdown-content {
+    display: none;
+    position: absolute;
+    min-width: 190px;
+    background-color: #f9f9f9;
+    box-shadow: 3px 2px 6px -2px rgba(0,0,0,0.64);
+    z-index: 2;
+}
+
+.navbar .dropdown-content a {
+    float: none;
+    display: block;
+    text-align: left;
+}
+
+.navbar .dropdown:hover .dropdown-content {
+    display: block;
+}
+
+.admin-marker {
+    color: #125CAA;
+    font-size: 12px;
+}
+
+.adminOnly {
+       display: none;
+}
+
 input[type=button], input[type=submit], button {
        background: #12AD5E;
        height: 24px;
@@ -593,4 +644,4 @@ div.tooltip {
        -webkit-box-shadow: 2px 4px 1px 0px rgba(224,224,224,1);
        -moz-box-shadow: 2px 4px 1px 0px rgba(224,224,224,1);
        box-shadow: 2px 4px 1px 0px rgba(224,224,224,1);
-}
\ No newline at end of file
+}
diff --git a/ignite-tc-helper-web/src/main/webapp/js/common-1.7.js 
b/ignite-tc-helper-web/src/main/webapp/js/common-1.7.js
index 37587dc7..7d440a5c 100644
--- a/ignite-tc-helper-web/src/main/webapp/js/common-1.7.js
+++ b/ignite-tc-helper-web/src/main/webapp/js/common-1.7.js
@@ -67,6 +67,15 @@ function isLoginUrl(url) {
     }
 }
 
+function escapeHtml(str) {
+    return String(str == null ? "" : str)
+        .replace(/&/g, "&amp;")
+        .replace(/</g, "&lt;")
+        .replace(/>/g, "&gt;")
+        .replace(/"/g, "&quot;")
+        .replace(/'/g, "&#039;");
+}
+
 function currentBackref() {
     if (isLoginUrl(window.location.href))
         return "/";
@@ -81,13 +90,18 @@ function showErrInLoadStatus(jqXHR, exception) {
     } else if (jqXHR.status === 404) {
         $("#loadStatus").html('Requested page not found. [404]');
     } else if (jqXHR.status === 401) {
-        $("#loadStatus").html('Unauthorized [401]');
+        var authMsg = isDefinedAndFilled(jqXHR.responseText)
+            ? jqXHR.responseText
+            : 'Unauthorized [401]';
+
+        $("#loadStatus").text(authMsg);
 
         if (window.location.pathname === "/login.html")
             return;
 
         setTimeout(function() {
-            window.location.href = "/login.html?backref=" + 
encodeURIComponent(currentBackref());
+            window.location.href = "/login.html?authError=" + 
encodeURIComponent(authMsg)
+                + "&backref=" + encodeURIComponent(currentBackref());
         }, 1000);
     } else if (jqXHR.status === 403) {
         $("#loadStatus").html('Forbidden [403]');
@@ -208,7 +222,11 @@ function showMenu(menuData) {
 
         res += "<a href='/monitoring.html'>Server state</a>";
 
-        res += "<a id='userName' href='/user.html'>" + userName + "</a>";
+        if (menuData.admin) {
+            res += adminUsersMenu(menuData.users);
+        }
+
+        res += "<a id='userName' href='/user.html'>" + escapeHtml(userName) + 
"</a>";
         var logout = "/login.html" + "?exit=true&backref=" + 
encodeURIComponent(window.location.href);
         res += "<a href='" + logout + "'>Logout</a>";
 
@@ -219,6 +237,30 @@ function showMenu(menuData) {
     $(document.body).prepend(res);
 }
 
+function adminUsersMenu(users) {
+    if (!Array.isArray(users) || users.length === 0)
+        return "";
+
+    var res = "<div class='dropdown'>";
+    res += "<button class='dropbtn'>Users</button>";
+    res += "<div class='dropdown-content'>";
+
+    for (var i = 0; i < users.length; i++) {
+        var user = users[i];
+        var label = escapeHtml(user.displayName || user.username);
+
+        if (user.admin)
+            label += " <span class='admin-marker'>admin</span>";
+
+        res += "<a href='/user.html?login=" + 
encodeURIComponent(user.username) + "'>" + label + "</a>";
+    }
+
+    res += "</div>";
+    res += "</div>";
+
+    return res;
+}
+
 
 function authorizeServer() {
     $.ajax({
diff --git a/ignite-tc-helper-web/src/main/webapp/login.html 
b/ignite-tc-helper-web/src/main/webapp/login.html
index d2786dfe..6baa2d00 100644
--- a/ignite-tc-helper-web/src/main/webapp/login.html
+++ b/ignite-tc-helper-web/src/main/webapp/login.html
@@ -62,6 +62,10 @@
         tcHelperLogout();
      }
 
+     var authError = findGetParameter("authError");
+     if (isDefinedAndFilled(authError))
+        $("#loadStatus").text(authError);
+
      $("#loginForm").submit(function(e) {
          var url = "rest/login/login";
 
@@ -97,7 +101,9 @@
              window.location.href = "/";
          }
      } else {
-         $("#loadStatus").html('Login failed, please check username and 
password');
+         $("#loadStatus").text(isDefinedAndFilled(data.errorMessage)
+             ? data.errorMessage
+             : 'Login failed, please check username and password');
      }
  }
 
diff --git a/ignite-tc-helper-web/src/main/webapp/monitoring.html 
b/ignite-tc-helper-web/src/main/webapp/monitoring.html
index 7fec39bc..c6a92b94 100644
--- a/ignite-tc-helper-web/src/main/webapp/monitoring.html
+++ b/ignite-tc-helper-web/src/main/webapp/monitoring.html
@@ -16,15 +16,39 @@
 </head>
 <body>
 <script>
+    var monitoringAdmin = false;
+
     $(document).ready(function() {
         $.getScript("js/common-1.7.js", function(data, textStatus, jqxhr){ });
 
         $( document ).tooltip();
 
-        loadData();
-        setInterval(loadAiPromptsData, 5000);
+        $.ajax({
+            url: "/rest/user/currentUserName",
+            success: function(result) {
+                monitoringAdmin = result.admin === true;
+
+                initAdminControls();
+                loadData();
+                setInterval(loadAiPromptsData, 5000);
+            },
+            error: function() {
+                monitoringAdmin = false;
+
+                initAdminControls();
+                loadData();
+                setInterval(loadAiPromptsData, 5000);
+            }
+        });
     });
 
+    function initAdminControls() {
+        if (monitoringAdmin)
+            $(".adminOnly").show();
+        else
+            $(".adminOnly").hide();
+    }
+
     function loadPofilingData() {
         $.ajax({
             url: "rest/monitoring/profiling",
@@ -57,21 +81,23 @@
             error: showErrInLoadStatus
         });
 
-        $.ajax({
-            url: "rest/monitoring/tasks",
-            success: function(result) {
-                $("#loadStatus").html("");
-
-                showTasks(result);
-            },
-            error: showErrInLoadStatus
-        });
-
-        $.ajax({
-            url: "rest/monitoring/appLogSummaryLink",
-            success: showAppLogSummaryLink,
-            error: showErrInLoadStatus
-        });
+        if (monitoringAdmin) {
+            $.ajax({
+                url: "rest/monitoring/tasks",
+                success: function(result) {
+                    $("#loadStatus").html("");
+
+                    showTasks(result);
+                },
+                error: showErrInLoadStatus
+            });
+
+            $.ajax({
+                url: "rest/monitoring/appLogSummaryLink",
+                success: showAppLogSummaryLink,
+                error: showErrInLoadStatus
+            });
+        }
 
         loadPofilingData();
         loadRequestData();
@@ -112,7 +138,8 @@
         res += "<th>Count</th>";
         res += "<th>End</th>";
         res += "<th>Result</th>";
-        res += "<th>Actions</th>";
+        if (monitoringAdmin)
+            res += "<th>Actions</th>";
         res += "</tr>";
         for (var i = 0; i < result.length; i++) {
             var task = result[i];
@@ -126,7 +153,8 @@
             res += "<td>" + escapeHtml(task.count) + "</td>";
             res += "<td>" + escapeHtml(task.end) + "</td>";
             res += "<td>" + escapeHtml(task.result) + "</td>";
-            res += "<td><a target='_blank' href='" + logUrl + 
"'>Warnings/errors</a></td>";
+            if (monitoringAdmin)
+                res += "<td><a target='_blank' href='" + logUrl + 
"'>Warnings/errors</a></td>";
             res += "</tr>";
         }
         res += "</table>";
@@ -277,6 +305,9 @@
     }
 
     function testSlackNotification() {
+        if (!monitoringAdmin)
+            return false;
+
         $.ajax({
             url: "rest/monitoring/testSlackNotification",
             method: "post",
@@ -292,6 +323,9 @@
     }
 
     function testEmailNotification() {
+        if (!monitoringAdmin)
+            return false;
+
         $.ajax({
             url: "rest/monitoring/testEmailNotification",
             method: "post",
@@ -318,10 +352,12 @@
 
 </script>
 
-Tasks Monitoring Data:
-<div id="appLogSummary" style="font-family: monospace"></div>
-<div id="tasks" style="font-family: monospace"></div>
-<br>
+<div class="adminOnly">
+    Tasks Monitoring Data:
+    <div id="appLogSummary" style="font-family: monospace"></div>
+    <div id="tasks" style="font-family: monospace"></div>
+    <br>
+</div>
 
 <hr>
 <b>AI Prompt Requests:</b>
@@ -329,12 +365,12 @@ Tasks Monitoring Data:
 <br>
 
 <hr>
-<b>REST Request Timings:</b> <button onclick="resetRequests()">Reset</button>
+<b>REST Request Timings:</b> <button class="adminOnly" 
onclick="resetRequests()">Reset</button>
 <div id="requests" style="font-family: monospace"></div>
 <br>
 
 <hr>
-<b>Method Profiling Data:</b> <button onclick="resetProfiling()">Reset</button>
+<b>Method Profiling Data:</b> <button class="adminOnly" 
onclick="resetProfiling()">Reset</button>
 <div id="profiling" style="font-family: monospace"></div>
 <br>
 
@@ -345,6 +381,7 @@ Tasks Monitoring Data:
 <br>
 
 <hr>
+<div class="adminOnly">
 <b>Test Slack notification:</b> <button 
onclick="testSlackNotification()">Send</button>
 <b>Test Email notification:</b>
 
@@ -353,6 +390,7 @@ Tasks Monitoring Data:
 </form>
 
 <button onclick="testEmailNotification()">Send</button>
+</div>
 
 <br>
 
diff --git a/ignite-tc-helper-web/src/main/webapp/user.html 
b/ignite-tc-helper-web/src/main/webapp/user.html
index dc2834bc..3bbd1af3 100644
--- a/ignite-tc-helper-web/src/main/webapp/user.html
+++ b/ignite-tc-helper-web/src/main/webapp/user.html
@@ -18,6 +18,7 @@
 <script>
 $(document).ready(function() {
     $(document).tooltip();
+    loadUsersList();
     loadData();
 
     var username = findGetParameter("username");
@@ -87,6 +88,7 @@ function doResetCreds() {
     $.ajax({
         type: "POST",
         url: url,
+        data: parmsForRest().replace(/^\?/, ""),
         contentType: "application/x-www-form-urlencoded; charset=utf-8",
         success: function(data) {
              loadData();
@@ -146,6 +148,52 @@ function loadData() {
     });
 }
 
+function loadUsersList() {
+    $.ajax({
+        url: "rest/user/currentUserName",
+        success: showUsersList,
+        error: function() {
+            $("#adminUsersBlock").hide();
+        }
+    });
+}
+
+function showUsersList(menuData) {
+    if (menuData.admin !== true) {
+        $("#adminUsersBlock").hide();
+
+        return;
+    }
+
+    var users = Array.isArray(menuData.users) ? menuData.users : [];
+    var res = "";
+
+    if (users.length === 0) {
+        res = "No other users";
+    }
+    else {
+        res += "<table class='stat'>";
+        res += "<tr><th>User</th><th>Login</th><th>Role</th></tr>";
+
+        for (var i = 0; i < users.length; i++) {
+            var user = users[i];
+            var login = user.username || "";
+            var label = user.displayName || login;
+
+            res += "<tr>";
+            res += "<td><a href='/user.html?login=" + 
encodeURIComponent(login) + "'>" + escapeHtml(label) + "</a></td>";
+            res += "<td>" + escapeHtml(login) + "</td>";
+            res += "<td>" + (user.admin ? "admin" : "") + "</td>";
+            res += "</tr>";
+        }
+
+        res += "</table>";
+    }
+
+    $("#adminUsers").html(res);
+    $("#adminUsersBlock").show();
+}
+
 function setupEditDialog() {
     $("#btnSaveUserData").button().on("click", function() {
         sendUserData();
@@ -209,6 +257,16 @@ function showData(userUi) {
             {
                 "data": "servicePassword",
                 title: "Password"
+            },
+            {
+                "data": "stale",
+                title: "Status",
+                "render": function(data, type, row) {
+                    if (data === true)
+                        return "Stale" + (isDefinedAndFilled(row.staleReason) 
? ": " + row.staleReason : "");
+
+                    return "Active";
+                }
             }
         ]
     });
@@ -216,6 +274,10 @@ function showData(userUi) {
 </script>
 
 <div id="form_container">
+    <div id="adminUsersBlock" style="display: none">
+        <h3>Users</h3>
+        <div id="adminUsers"></div>
+    </div>
 
     <form id="userData" class="appnitro">
         <div class="form_description">
@@ -290,6 +352,7 @@ function showData(userUi) {
                 <th>Service Id</th>
                 <th>Login</th>
                 <th>Password</th>
+                <th>Status</th>
             </tr>
             </thead>
         </table>
@@ -332,4 +395,4 @@ function showData(userUi) {
     </div>
 </div>
 </body>
-</html>
\ No newline at end of file
+</html>
diff --git 
a/ignite-tc-helper-web/src/test/java/org/apache/ignite/ci/user/LoginAuthTest.java
 
b/ignite-tc-helper-web/src/test/java/org/apache/ignite/ci/user/LoginAuthTest.java
index 5b1b7f95..8021c1ff 100644
--- 
a/ignite-tc-helper-web/src/test/java/org/apache/ignite/ci/user/LoginAuthTest.java
+++ 
b/ignite-tc-helper-web/src/test/java/org/apache/ignite/ci/user/LoginAuthTest.java
@@ -23,8 +23,11 @@ import java.util.Map;
 import java.util.concurrent.atomic.AtomicReference;
 import javax.ws.rs.container.ContainerRequestContext;
 import org.apache.ignite.tcbot.engine.user.UserAndSessionsStorage;
+import org.apache.ignite.tcservice.model.user.GroupRef;
+import org.apache.ignite.tcservice.model.user.Groups;
 import org.apache.ignite.tcservice.model.user.User;
 import org.apache.ignite.tcservice.login.ITcLogin;
+import org.apache.ignite.tcservice.login.TcLoginResult;
 import org.apache.ignite.tcbot.common.util.Base64Util;
 import org.apache.ignite.ci.web.auth.AuthenticationFilter;
 import org.apache.ignite.ci.web.rest.login.Login;
@@ -43,7 +46,7 @@ import static org.mockito.Mockito.doAnswer;
 import static org.mockito.Mockito.when;
 
 public class LoginAuthTest {
-    private ITcLogin tcLogin = (serverId, username, password) -> new User();
+    private ITcLogin tcLogin = (serverId, username, password) -> 
"password".equals(password) ? new User() : null;
 
     @Test
     public void testNewUserLogin() {
@@ -107,6 +110,99 @@ public class LoginAuthTest {
         return ctx;
     }
 
+    @Test
+    public void testAdminFlagLoadedFromTeamcityGroups() {
+        UserAndSessionsStorage storage = mockOneSessionStor();
+
+        Login login = createLogin();
+
+        ITcLogin adminTcLogin = (serverId, username, password) -> {
+            User user = new User();
+            user.username = username;
+            user.setGroups(new Groups(new GroupRef("IGNITE_COMMITER", "Ignite 
Tests Admins")));
+
+            return user;
+        };
+
+        LoginResponse loginResponse = login.doLogin("admin", "password", 
storage, "public", Collections.emptySet(),
+            adminTcLogin, Collections.singleton("IGNITE_COMMITER"));
+
+        assertNotNull(loginResponse.fullToken);
+        assertTrue(storage.getUser("admin").isAdmin());
+        assertNotNull(storage.getUser("admin").adminLastCheckedTs);
+    }
+
+    @Test
+    public void testAdminFlagIsFalseWhenTeamcityGroupIsMissing() {
+        UserAndSessionsStorage storage = mockOneSessionStor();
+
+        Login login = createLogin();
+
+        ITcLogin regularTcLogin = (serverId, username, password) -> {
+            User user = new User();
+            user.username = username;
+            user.setGroups(new Groups(new GroupRef("OTHER_GROUP", "Other 
Group")));
+
+            return user;
+        };
+
+        LoginResponse loginResponse = login.doLogin("user", "password", 
storage, "public", Collections.emptySet(),
+            regularTcLogin, Collections.singleton("IGNITE_COMMITER"));
+
+        assertNotNull(loginResponse.fullToken);
+        assertFalse(storage.getUser("user").isAdmin());
+    }
+
+    @Test
+    public void testAdminGroupMatchingDoesNotUseDisplayName() {
+        UserAndSessionsStorage storage = mockOneSessionStor();
+
+        Login login = createLogin();
+
+        ITcLogin adminTcLogin = (serverId, username, password) -> {
+            User user = new User();
+            user.username = username;
+            user.setGroups(new Groups(new GroupRef("IGNITE_COMMITER", "Ignite 
Tests Admins")));
+
+            return user;
+        };
+
+        LoginResponse loginResponse = login.doLogin("admin", "password", 
storage, "public", Collections.emptySet(),
+            adminTcLogin, Collections.singleton("Ignite Tests Admins"));
+
+        assertNotNull(loginResponse.fullToken);
+        assertFalse(storage.getUser("admin").isAdmin());
+    }
+
+    @Test
+    public void testAdminFlagIsPreservedWhenTeamcityIsUnavailable() {
+        UserAndSessionsStorage storage = mockOneSessionStor();
+
+        Login login = createLogin();
+
+        ITcLogin adminTcLogin = (serverId, username, password) -> {
+            User user = new User();
+            user.username = username;
+            user.setGroups(new Groups(new GroupRef("IGNITE_COMMITER", "Ignite 
Tests Admins")));
+
+            return user;
+        };
+
+        LoginResponse loginResponse = login.doLogin("admin", "password", 
storage, "public", Collections.emptySet(),
+            adminTcLogin, Collections.singleton("IGNITE_COMMITER"));
+
+        assertNotNull(loginResponse.fullToken);
+        assertTrue(storage.getUser("admin").isAdmin());
+
+        ITcLogin unavailableTcLogin = (serverId, username, password) -> null;
+
+        loginResponse = login.doLogin("admin", "password", storage, "public", 
Collections.emptySet(),
+            unavailableTcLogin, Collections.singleton("IGNITE_COMMITER"));
+
+        assertNotNull(loginResponse.fullToken);
+        assertTrue(storage.getUser("admin").isAdmin());
+    }
+
     @Test
     public void testUserCredentials() {
         UserAndSessionsStorage storage = mockOneSessionStor();
@@ -136,13 +232,206 @@ public class LoginAuthTest {
         assertEquals(password, creds.getPassword(srvId));
     }
 
+    @Test
+    public void testChangedTeamcityPasswordReplacesStoredCredentials() {
+        UserAndSessionsStorage storage = mockOneSessionStor();
+
+        Login login = createLogin();
+
+        LoginResponse loginResponse = login.doLogin("user", "password", 
storage, "public", Collections.emptySet(),
+            tcLogin);
+
+        assertNotNull(loginResponse.fullToken);
+
+        ITcLogin changedPasswordLogin = (serverId, username, password) -> 
"new-password".equals(password)
+            ? new User()
+            : null;
+
+        loginResponse = login.doLogin("user", "new-password", storage, 
"public", Collections.emptySet(),
+            changedPasswordLogin);
+
+        assertNotNull(loginResponse.fullToken);
+        
assertTrue(storage.getUser("user").getCredentialsList().stream().anyMatch(TcHelperUser.Credentials::isStale));
+
+        AuthenticationFilter authenticationFilter = new AuthenticationFilter();
+
+        ContainerRequestContext ctx = mockCtxWithParams();
+
+        assertTrue(authenticationFilter.authenticate(ctx, 
loginResponse.fullToken, storage));
+
+        ITcBotUserCreds creds = 
(ITcBotUserCreds)ctx.getProperty(ITcBotUserCreds._KEY);
+
+        assertEquals("new-password", creds.getPassword("public"));
+    }
+
+    @Test
+    public void 
testNewPasswordRejectedByTeamcityKeepsStoredCredentialsActive() {
+        UserAndSessionsStorage storage = mockOneSessionStor();
+
+        Login login = createLogin();
+
+        LoginResponse initialLogin = login.doLogin("user", "password", 
storage, "public", Collections.emptySet(),
+            tcLogin);
+
+        assertNotNull(initialLogin.fullToken);
+
+        LoginResponse failedLogin = login.doLogin("user", "mistyped-password", 
storage, "public",
+            Collections.emptySet(), 
tcLoginWithFallback(TcLoginResult.unauthorized()));
+
+        assertNull(failedLogin.fullToken);
+        assertNotNull(failedLogin.errorMessage);
+        assertTrue(storage.getUser("user").getCredentialsList().stream()
+            .noneMatch(TcHelperUser.Credentials::isStale));
+        assertEquals("password", credentialPassword(storage, 
initialLogin.fullToken, "public"));
+    }
+
+    @Test
+    public void testNewPasswordNotCheckedKeepsStoredCredentialsActive() {
+        UserAndSessionsStorage storage = mockOneSessionStor();
+
+        Login login = createLogin();
+
+        LoginResponse initialLogin = login.doLogin("user", "password", 
storage, "public", Collections.emptySet(),
+            tcLogin);
+
+        assertNotNull(initialLogin.fullToken);
+
+        LoginResponse failedLogin = login.doLogin("user", 
"possible-new-password", storage, "public",
+            Collections.emptySet(), 
tcLoginWithFallback(TcLoginResult.notChecked()));
+
+        assertNull(failedLogin.fullToken);
+        assertEquals("Password does not match stored bot credentials", 
failedLogin.errorMessage);
+        assertTrue(storage.getUser("user").getCredentialsList().stream()
+            .noneMatch(TcHelperUser.Credentials::isStale));
+        assertEquals("password", credentialPassword(storage, 
initialLogin.fullToken, "public"));
+    }
+
+    @Test
+    public void testStoredPasswordAllowsLoginWhenTeamcityIsNotChecked() {
+        UserAndSessionsStorage storage = mockOneSessionStor();
+
+        Login login = createLogin();
+
+        LoginResponse initialLogin = login.doLogin("user", "password", 
storage, "public", Collections.emptySet(),
+            tcLogin);
+
+        assertNotNull(initialLogin.fullToken);
+
+        LoginResponse offlineLogin = login.doLogin("user", "password", 
storage, "public", Collections.emptySet(),
+            tcLoginWithFallback(TcLoginResult.notChecked()));
+
+        assertNotNull(offlineLogin.fullToken);
+        assertTrue(storage.getUser("user").getCredentialsList().stream()
+            .noneMatch(TcHelperUser.Credentials::isStale));
+        assertEquals("password", credentialPassword(storage, 
offlineLogin.fullToken, "public"));
+    }
+
+    @Test
+    public void 
testPasswordRotationStalesServiceWithoutAcceptedNewCredentials() {
+        UserAndSessionsStorage storage = mockOneSessionStor();
+
+        Login login = createLogin();
+
+        LoginResponse initialLogin = login.doLogin("user", "password", 
storage, "public",
+            Collections.singleton("aux"), tcLoginAccepting(
+                "public", "password",
+                "aux", "password"
+            ));
+
+        assertNotNull(initialLogin.fullToken);
+        assertEquals("password", credentialPassword(storage, 
initialLogin.fullToken, "public"));
+        assertEquals("password", credentialPassword(storage, 
initialLogin.fullToken, "aux"));
+
+        LoginResponse rotatedLogin = login.doLogin("user", "new-password", 
storage, "public",
+            Collections.singleton("aux"), 
tcLoginAcceptingWithFallback(TcLoginResult.unauthorized(),
+                "public", "new-password"
+            ));
+
+        assertNotNull(rotatedLogin.fullToken);
+        assertEquals("new-password", credentialPassword(storage, 
rotatedLogin.fullToken, "public"));
+        assertFalse(credentials(storage, 
rotatedLogin.fullToken).hasAccess("aux"));
+        assertNull(storage.getUser("user").getCredentials("aux"));
+    }
+
+    @Test
+    public void 
testPasswordRotationUpdatesAdditionalServiceWhenNewCredentialsAccepted() {
+        UserAndSessionsStorage storage = mockOneSessionStor();
+
+        Login login = createLogin();
+
+        LoginResponse initialLogin = login.doLogin("user", "password", 
storage, "public",
+            Collections.singleton("aux"), tcLoginAccepting(
+                "public", "password",
+                "aux", "password"
+            ));
+
+        assertNotNull(initialLogin.fullToken);
+
+        LoginResponse rotatedLogin = login.doLogin("user", "new-password", 
storage, "public",
+            Collections.singleton("aux"), tcLoginAccepting(
+                "public", "new-password",
+                "aux", "new-password"
+            ));
+
+        assertNotNull(rotatedLogin.fullToken);
+        assertEquals("new-password", credentialPassword(storage, 
rotatedLogin.fullToken, "public"));
+        assertEquals("new-password", credentialPassword(storage, 
rotatedLogin.fullToken, "aux"));
+        assertTrue(storage.getUser("user").getCredentialsList().stream()
+            .anyMatch(TcHelperUser.Credentials::isStale));
+    }
+
+    @Test
+    public void testNewUserRejectedByTeamcityIsNotSaved() {
+        UserAndSessionsStorage storage = mockOneSessionStor();
+
+        LoginResponse failedLogin = createLogin().doLogin("user", "password", 
storage, "public",
+            Collections.emptySet(), 
tcLoginWithFallback(TcLoginResult.unauthorized()));
+
+        assertNull(failedLogin.fullToken);
+        assertNotNull(failedLogin.errorMessage);
+        assertNull(storage.getUser("user"));
+    }
+
+    @Test
+    public void testOldLocalPasswordRejectedByTeamcityKeepsCredentialsActive() 
{
+        UserAndSessionsStorage storage = mockOneSessionStor();
+
+        Login login = createLogin();
+
+        LoginResponse loginResponse = login.doLogin("user", "password", 
storage, "public", Collections.emptySet(),
+            tcLogin);
+
+        assertNotNull(loginResponse.fullToken);
+
+        ITcLogin unauthorizedLogin = new ITcLogin() {
+            @Override public User checkServiceUserAndPassword(String srvId, 
String username, String pwd) {
+                return null;
+            }
+
+            @Override public TcLoginResult 
checkServiceUserAndPasswordResult(String srvId, String username,
+                String pwd) {
+                return TcLoginResult.unauthorized();
+            }
+        };
+
+        loginResponse = login.doLogin("user", "password", storage, "public", 
Collections.emptySet(),
+            unauthorizedLogin);
+
+        assertNull(loginResponse.fullToken);
+        assertNotNull(loginResponse.errorMessage);
+        assertNotNull(storage.getUser("user").getCredentials("public"));
+        assertTrue(storage.getUser("user").getCredentialsList().stream()
+            .noneMatch(TcHelperUser.Credentials::isStale));
+    }
+
     @Test
     public void testAuthFailedWithBrokenToken() {
         UserAndSessionsStorage storage = mockOneSessionStor();
 
         Login login = createLogin();
 
-        String fullToken = login.doLogin("user", "password", storage, 
"public", Collections.emptySet(), tcLogin).fullToken;
+        String fullToken = login.doLogin("user", "password", storage, 
"public", Collections.emptySet(),
+            tcLogin).fullToken;
 
         int sepIdx = fullToken.indexOf(':');
         String brokenToken = fullToken.substring(0, sepIdx + 1) +
@@ -160,4 +449,55 @@ public class LoginAuthTest {
     @NotNull public Login createLogin() {
         return new Login();
     }
+
+    private static ITcLogin tcLoginWithFallback(TcLoginResult fallback) {
+        return tcLoginAcceptingWithFallback(fallback);
+    }
+
+    private static ITcLogin tcLoginAccepting(String... 
acceptedServerPasswordPairs) {
+        return tcLoginAcceptingWithFallback(TcLoginResult.notChecked(), 
acceptedServerPasswordPairs);
+    }
+
+    private static ITcLogin tcLoginAcceptingWithFallback(TcLoginResult 
fallback,
+        String... acceptedServerPasswordPairs) {
+        Map<String, Boolean> accepted = new HashMap<>();
+
+        for (int i = 0; i < acceptedServerPasswordPairs.length; i += 2)
+            accepted.put(loginKey(acceptedServerPasswordPairs[i], 
acceptedServerPasswordPairs[i + 1]), true);
+
+        return new ITcLogin() {
+            @Override public User checkServiceUserAndPassword(String srvId, 
String username, String pwd) {
+                return checkServiceUserAndPasswordResult(srvId, username, 
pwd).user();
+            }
+
+            @Override public TcLoginResult 
checkServiceUserAndPasswordResult(String srvId, String username,
+                String pwd) {
+                if (accepted.containsKey(loginKey(srvId, pwd))) {
+                    User user = new User();
+                    user.username = username;
+
+                    return TcLoginResult.accepted(user);
+                }
+
+                return fallback;
+            }
+        };
+    }
+
+    private static String loginKey(String srvId, String pwd) {
+        return srvId + ":" + pwd;
+    }
+
+    private ITcBotUserCreds credentials(UserAndSessionsStorage storage, String 
fullToken) {
+        AuthenticationFilter authenticationFilter = new AuthenticationFilter();
+        ContainerRequestContext ctx = mockCtxWithParams();
+
+        assertTrue(authenticationFilter.authenticate(ctx, fullToken, storage));
+
+        return (ITcBotUserCreds)ctx.getProperty(ITcBotUserCreds._KEY);
+    }
+
+    private String credentialPassword(UserAndSessionsStorage storage, String 
fullToken, String srvId) {
+        return credentials(storage, fullToken).getPassword(srvId);
+    }
 }
diff --git 
a/ignite-tc-helper-web/src/test/java/org/apache/ignite/ci/web/rest/login/UserAdminRefreshServiceTest.java
 
b/ignite-tc-helper-web/src/test/java/org/apache/ignite/ci/web/rest/login/UserAdminRefreshServiceTest.java
new file mode 100644
index 00000000..21acf4f2
--- /dev/null
+++ 
b/ignite-tc-helper-web/src/test/java/org/apache/ignite/ci/web/rest/login/UserAdminRefreshServiceTest.java
@@ -0,0 +1,141 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.ignite.ci.web.rest.login;
+
+import java.lang.reflect.Field;
+import java.util.Collections;
+import java.util.stream.Stream;
+import javax.inject.Provider;
+import org.apache.ignite.ci.tcbot.ITcBotBgAuth;
+import org.apache.ignite.ci.user.ITcBotUserCreds;
+import org.apache.ignite.ci.user.TcHelperUser;
+import org.apache.ignite.tcbot.common.exeption.ServiceUnavailableException;
+import org.apache.ignite.tcbot.engine.conf.ITcBotConfig;
+import org.apache.ignite.tcbot.engine.user.IUserStorage;
+import org.apache.ignite.tcservice.TeamcityServiceConnection;
+import org.apache.ignite.tcservice.model.user.User;
+import org.junit.Test;
+
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.same;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+public class UserAdminRefreshServiceTest {
+    @Test
+    public void staleAdminFlagIsInvalidatedAfterSuccessfulTeamcityRefresh() 
throws Exception {
+        TcHelperUser user = user("admin", true);
+        user.adminLastCheckedTs = 1L;
+
+        IUserStorage users = mock(IUserStorage.class);
+        when(users.allUsers()).thenReturn(Stream.of(user));
+
+        User tcUser = new User();
+        tcUser.username = "admin";
+
+        TeamcityServiceConnection tcConn = 
mock(TeamcityServiceConnection.class);
+        when(tcConn.getUserByUsername("admin")).thenReturn(tcUser);
+
+        UserAdminRefreshService svc = service(users, tcConn);
+
+        svc.refreshStaleAdmins();
+
+        assertFalse(user.isAdmin());
+        verify(users).putUser(eq("admin"), same(user));
+    }
+
+    @Test
+    public void staleAdminFlagIsPreservedWhenTeamcityIsUnavailable() throws 
Exception {
+        TcHelperUser user = user("admin", true);
+        user.adminLastCheckedTs = 1L;
+
+        IUserStorage users = mock(IUserStorage.class);
+        when(users.allUsers()).thenReturn(Stream.of(user));
+
+        TeamcityServiceConnection tcConn = 
mock(TeamcityServiceConnection.class);
+        when(tcConn.getUserByUsername("admin")).thenThrow(
+            new ServiceUnavailableException("Service unavailable", 
"http://tc";, 503, -1));
+
+        UserAdminRefreshService svc = service(users, tcConn);
+
+        svc.refreshStaleAdmins();
+
+        assertTrue(user.isAdmin());
+        verify(users, never()).putUser(eq("admin"), same(user));
+    }
+
+    private static UserAdminRefreshService service(IUserStorage users, 
TeamcityServiceConnection tcConn)
+        throws Exception {
+        ITcBotConfig cfg = mock(ITcBotConfig.class);
+        when(cfg.primaryServerCode()).thenReturn("public");
+        
when(cfg.botAdminGroups()).thenReturn(Collections.singleton("IGNITE_COMMITER"));
+
+        ITcBotBgAuth bgAuth = mock(ITcBotBgAuth.class);
+        when(bgAuth.getServerAuthorizerCreds()).thenReturn(creds());
+
+        Provider<TeamcityServiceConnection> tcFactory = () -> tcConn;
+
+        UserAdminRefreshService svc = new UserAdminRefreshService();
+
+        setField(svc, "users", users);
+        setField(svc, "cfg", cfg);
+        setField(svc, "bgAuth", bgAuth);
+        setField(svc, "tcFactory", tcFactory);
+
+        return svc;
+    }
+
+    private static TcHelperUser user(String username, boolean admin) {
+        TcHelperUser user = new TcHelperUser();
+        user.username = username;
+        user.setAdmin(admin);
+
+        return user;
+    }
+
+    private static ITcBotUserCreds creds() {
+        return new ITcBotUserCreds() {
+            @Override public String getUser(String srvCode) {
+                return "bot";
+            }
+
+            @Override public String getPassword(String srvCode) {
+                return "password";
+            }
+
+            @Override public String getPrincipalId() {
+                return "bot";
+            }
+
+            @Override public byte[] getUserKey() {
+                return new byte[0];
+            }
+        };
+    }
+
+    private static void setField(Object target, String fieldName, Object val) 
throws Exception {
+        Field field = 
UserAdminRefreshService.class.getDeclaredField(fieldName);
+
+        field.setAccessible(true);
+        field.set(target, val);
+    }
+}
diff --git 
a/ignite-tc-helper-web/src/test/java/org/apache/ignite/ci/web/rest/login/UserServiceTest.java
 
b/ignite-tc-helper-web/src/test/java/org/apache/ignite/ci/web/rest/login/UserServiceTest.java
new file mode 100644
index 00000000..c9dd36ce
--- /dev/null
+++ 
b/ignite-tc-helper-web/src/test/java/org/apache/ignite/ci/web/rest/login/UserServiceTest.java
@@ -0,0 +1,182 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.ignite.ci.web.rest.login;
+
+import java.io.IOException;
+import java.lang.reflect.Field;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import javax.servlet.ServletContext;
+import javax.servlet.http.HttpServletRequest;
+import javax.ws.rs.ForbiddenException;
+import javax.ws.rs.core.Form;
+import org.apache.ignite.ci.user.ITcBotUserCreds;
+import org.apache.ignite.ci.user.TcHelperUser;
+import org.apache.ignite.tcbot.common.application.TcBotApplicationContext;
+import org.apache.ignite.tcbot.engine.user.IUserStorage;
+import org.junit.Test;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.same;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+public class UserServiceTest {
+    @Test
+    public void adminSavesRequestedUserData() throws Exception {
+        TcHelperUser admin = user("admin", true);
+        TcHelperUser other = user("other", false);
+
+        IUserStorage users = mock(IUserStorage.class);
+        when(users.getUser("admin")).thenReturn(admin);
+        when(users.getUser("other")).thenReturn(other);
+
+        UserService svc = service(users, creds("admin"));
+
+        Form form = new Form();
+        form.param("notify_master", "1");
+
+        svc.saveUserData("other", "[email protected]", "Other User", form);
+
+        assertEquals("Admin User", admin.fullName);
+        assertEquals("[email protected]", admin.email);
+        assertFalse(admin.isSubscribedToBranch("master"));
+
+        assertEquals("Other User", other.fullName);
+        assertEquals("[email protected]", other.email);
+        assertTrue(other.isSubscribedToBranch("master"));
+
+        verify(users).putUser(eq("other"), same(other));
+        verify(users, never()).putUser(eq("admin"), same(admin));
+    }
+
+    @Test
+    public void adminResetsRequestedUserCredentials() throws Exception {
+        TcHelperUser admin = user("admin", true);
+        TcHelperUser other = user("other", false);
+        
other.getOrCreateCreds("apache").setLogin("other").setPassword("password", new 
byte[16]);
+
+        IUserStorage users = mock(IUserStorage.class);
+        when(users.getUser("admin")).thenReturn(admin);
+        when(users.getUser("other")).thenReturn(other);
+
+        UserService svc = service(users, creds("admin"));
+
+        svc.resetCredentials("other");
+
+        assertTrue(other.getCredentialsList().isEmpty());
+        assertEquals("Admin User", admin.fullName);
+
+        verify(users).putUser(eq("other"), same(other));
+        verify(users, never()).putUser(eq("admin"), same(admin));
+    }
+
+    @Test(expected = ForbiddenException.class)
+    public void nonAdminCannotResetOtherUserCredentials() throws Exception {
+        TcHelperUser user = user("user", false);
+        TcHelperUser other = user("other", false);
+
+        IUserStorage users = mock(IUserStorage.class);
+        when(users.getUser("user")).thenReturn(user);
+        when(users.getUser("other")).thenReturn(other);
+
+        service(users, creds("user")).resetCredentials("other");
+    }
+
+    @Test
+    public void userPageHasExplicitAdminUsersList() throws IOException {
+        String html = new String(Files.readAllBytes(userHtml()), 
StandardCharsets.UTF_8);
+
+        assertTrue(html.contains("loadUsersList()"));
+        assertTrue(html.contains("id=\"adminUsersBlock\""));
+        assertTrue(html.contains("rest/user/currentUserName"));
+        assertTrue(html.contains("/user.html?login="));
+    }
+
+    private static UserService service(IUserStorage users, ITcBotUserCreds 
creds) throws Exception {
+        TcBotApplicationContext appCtx = mock(TcBotApplicationContext.class);
+        when(appCtx.getInstance(IUserStorage.class)).thenReturn(users);
+
+        ServletContext ctx = mock(ServletContext.class);
+        when(ctx.getAttribute(anyString())).thenReturn(appCtx);
+
+        HttpServletRequest req = mock(HttpServletRequest.class);
+        when(req.getAttribute(ITcBotUserCreds._KEY)).thenReturn(creds);
+
+        UserService svc = new UserService();
+
+        setField(svc, "ctx", ctx);
+        setField(svc, "req", req);
+
+        return svc;
+    }
+
+    private static TcHelperUser user(String username, boolean admin) {
+        TcHelperUser user = new TcHelperUser();
+        user.username = username;
+        user.fullName = username.substring(0, 1).toUpperCase() + 
username.substring(1) + " User";
+        user.email = username + "@example.org";
+        user.setAdmin(admin);
+
+        return user;
+    }
+
+    private static ITcBotUserCreds creds(String principalId) {
+        return new ITcBotUserCreds() {
+            @Override public String getUser(String srvCode) {
+                return null;
+            }
+
+            @Override public String getPassword(String srvCode) {
+                return null;
+            }
+
+            @Override public String getPrincipalId() {
+                return principalId;
+            }
+
+            @Override public byte[] getUserKey() {
+                return new byte[0];
+            }
+        };
+    }
+
+    private static void setField(Object target, String fieldName, Object val) 
throws Exception {
+        Field field = UserService.class.getDeclaredField(fieldName);
+
+        field.setAccessible(true);
+        field.set(target, val);
+    }
+
+    private static Path userHtml() {
+        Path projectPath = Paths.get("src/main/webapp/user.html");
+
+        if (Files.exists(projectPath))
+            return projectPath;
+
+        return Paths.get("ignite-tc-helper-web/src/main/webapp/user.html");
+    }
+}
diff --git 
a/ignite-tc-helper-web/src/test/java/org/apache/ignite/ci/web/rest/monitoring/MonitoringServiceSecurityTest.java
 
b/ignite-tc-helper-web/src/test/java/org/apache/ignite/ci/web/rest/monitoring/MonitoringServiceSecurityTest.java
index 160e7299..f9219381 100644
--- 
a/ignite-tc-helper-web/src/test/java/org/apache/ignite/ci/web/rest/monitoring/MonitoringServiceSecurityTest.java
+++ 
b/ignite-tc-helper-web/src/test/java/org/apache/ignite/ci/web/rest/monitoring/MonitoringServiceSecurityTest.java
@@ -24,6 +24,8 @@ import java.nio.file.Files;
 import java.nio.file.Path;
 import java.nio.file.Paths;
 import javax.annotation.security.PermitAll;
+import javax.annotation.security.RolesAllowed;
+import org.apache.ignite.ci.web.auth.AuthenticationFilter;
 import org.junit.Test;
 
 import static org.junit.Assert.assertFalse;
@@ -38,6 +40,20 @@ public class MonitoringServiceSecurityTest {
         
assertAuthRequired(MonitoringService.class.getMethod("getAiPromptRequests"));
     }
 
+    @Test
+    public void logEndpointsRequireAdminRole() throws NoSuchMethodException {
+        
assertAdminRequired(MonitoringService.class.getMethod("getAppLogSummaryLink"));
+        assertAdminRequired(MonitoringService.class.getMethod("getTaskLog", 
long.class, long.class));
+    }
+
+    @Test
+    public void mutationEndpointsRequireAdminRole() throws 
NoSuchMethodException {
+        
assertAdminRequired(MonitoringService.class.getMethod("resetProfiling"));
+        
assertAdminRequired(MonitoringService.class.getMethod("resetRequestStats"));
+        
assertAdminRequired(MonitoringService.class.getMethod("testSlackNotification"));
+        
assertAdminRequired(MonitoringService.class.getMethod("testEmailNotification", 
String.class));
+    }
+
     @Test
     public void requestTimingFieldsAreEscaped() throws IOException {
         String html = new String(Files.readAllBytes(monitoringHtml()), 
StandardCharsets.UTF_8);
@@ -50,10 +66,41 @@ public class MonitoringServiceSecurityTest {
         assertTrue(html.contains("String(str == null ? \"\" : str)"));
     }
 
+    @Test
+    public void notificationTestControlsAreHiddenForNonAdmins() throws 
IOException, NoSuchMethodException {
+        String html = new String(Files.readAllBytes(monitoringHtml()), 
StandardCharsets.UTF_8);
+        String css = new String(Files.readAllBytes(styleCss()), 
StandardCharsets.UTF_8);
+
+        assertTrue(html.contains("<div class=\"adminOnly\">"));
+        assertTrue(html.contains("testSlackNotification()"));
+        assertTrue(html.contains("testEmailNotification()"));
+        assertTrue(css.contains(".adminOnly"));
+        assertTrue(css.contains("display: none"));
+
+        
assertAdminRequired(MonitoringService.class.getMethod("testSlackNotification"));
+        
assertAdminRequired(MonitoringService.class.getMethod("testEmailNotification", 
String.class));
+    }
+
+    @Test
+    public void taskMonitoringBlockIsHiddenForNonAdmins() throws IOException {
+        String html = new String(Files.readAllBytes(monitoringHtml()), 
StandardCharsets.UTF_8);
+
+        assertTrue(html.contains("<div class=\"adminOnly\">\n    Tasks 
Monitoring Data:"));
+        assertTrue(html.contains("rest/monitoring/tasks"));
+        assertFalse(html.contains("Application warnings/errors are available 
for bot admins."));
+    }
+
     private static void assertAuthRequired(Method method) {
         assertFalse(method.isAnnotationPresent(PermitAll.class));
     }
 
+    private static void assertAdminRequired(Method method) {
+        RolesAllowed rolesAllowed = method.getAnnotation(RolesAllowed.class);
+
+        assertTrue(rolesAllowed != null);
+        
assertTrue(java.util.Arrays.asList(rolesAllowed.value()).contains(AuthenticationFilter.ADMIN_ROLE));
+    }
+
     private static Path monitoringHtml() {
         Path projectPath = Paths.get("src/main/webapp/monitoring.html");
 
@@ -62,4 +109,13 @@ public class MonitoringServiceSecurityTest {
 
         return 
Paths.get("ignite-tc-helper-web/src/main/webapp/monitoring.html");
     }
+
+    private static Path styleCss() {
+        Path projectPath = Paths.get("src/main/webapp/css/style-1.5.css");
+
+        if (Files.exists(projectPath))
+            return projectPath;
+
+        return 
Paths.get("ignite-tc-helper-web/src/main/webapp/css/style-1.5.css");
+    }
 }
diff --git 
a/tcbot-app-guice/src/main/java/org/apache/ignite/tcbot/app/guice/GuiceTcBotApplicationContext.java
 
b/tcbot-app-guice/src/main/java/org/apache/ignite/tcbot/app/guice/GuiceTcBotApplicationContext.java
index d654a0d6..49167a01 100644
--- 
a/tcbot-app-guice/src/main/java/org/apache/ignite/tcbot/app/guice/GuiceTcBotApplicationContext.java
+++ 
b/tcbot-app-guice/src/main/java/org/apache/ignite/tcbot/app/guice/GuiceTcBotApplicationContext.java
@@ -30,6 +30,7 @@ import org.apache.ignite.Ignite;
 import org.apache.ignite.ci.db.TcHelperDb;
 import org.apache.ignite.ci.observer.BuildObserver;
 import org.apache.ignite.ci.tcbot.issue.IssueDetector;
+import org.apache.ignite.ci.web.rest.login.UserAdminRefreshService;
 import org.apache.ignite.tcbot.common.application.TcBotApplicationContext;
 import org.apache.ignite.tcbot.common.monitoring.MonitoredTasks;
 import org.apache.ignite.tcbot.engine.cleaner.Cleaner;
@@ -89,6 +90,7 @@ class GuiceTcBotApplicationContext implements 
TcBotApplicationContext {
                     return;
 
                 getInstance(BuildObserver.class);
+                getInstance(UserAdminRefreshService.class).start();
                 ready.set(true);
             }
             catch (Exception e) {
diff --git 
a/tcbot-app-guice/src/main/java/org/apache/ignite/tcbot/app/guice/TcBotWebAppModule.java
 
b/tcbot-app-guice/src/main/java/org/apache/ignite/tcbot/app/guice/TcBotWebAppModule.java
index 871fcc4a..544b494e 100644
--- 
a/tcbot-app-guice/src/main/java/org/apache/ignite/tcbot/app/guice/TcBotWebAppModule.java
+++ 
b/tcbot-app-guice/src/main/java/org/apache/ignite/tcbot/app/guice/TcBotWebAppModule.java
@@ -32,6 +32,7 @@ import org.apache.ignite.ci.tcbot.TcBotBgAuthImpl;
 import org.apache.ignite.ci.tcbot.conf.LocalFilesBasedConfig;
 import org.apache.ignite.ci.tcbot.issue.IssueDetector;
 import org.apache.ignite.ci.tcbot.trends.MasterTrendsService;
+import org.apache.ignite.ci.web.rest.login.UserAdminRefreshService;
 import org.apache.ignite.ci.web.model.hist.VisasHistoryStorage;
 import org.apache.ignite.githubignited.GitHubIgnitedModule;
 import org.apache.ignite.jiraignited.JiraIgnitedModule;
@@ -84,6 +85,7 @@ public class TcBotWebAppModule extends AbstractModule {
         bind(BuildObserver.class).in(Scopes.SINGLETON);
         bind(VisasHistoryStorage.class).in(Scopes.SINGLETON);
         bind(Cleaner.class).in(Scopes.SINGLETON);
+        bind(UserAdminRefreshService.class).in(Scopes.SINGLETON);
 
         install(new TcBotPersistenceModule());
         install(new TeamcityIgnitedModule());
diff --git 
a/tcbot-common/src/main/java/org/apache/ignite/tcbot/common/util/HttpUtil.java 
b/tcbot-common/src/main/java/org/apache/ignite/tcbot/common/util/HttpUtil.java
index 27435147..e5428b4b 100644
--- 
a/tcbot-common/src/main/java/org/apache/ignite/tcbot/common/util/HttpUtil.java
+++ 
b/tcbot-common/src/main/java/org/apache/ignite/tcbot/common/util/HttpUtil.java
@@ -127,9 +127,13 @@ public class HttpUtil {
 
         int resCode = con.getResponseCode();
 
-        if (rspHeaders != null)
+        if (rspHeaders != null) {
             rspHeaders.keySet().forEach(k -> rspHeaders.put(k, 
con.getHeaderField(k)));
 
+            if (rspHeaders.containsKey("Response-Code"))
+                rspHeaders.put("Response-Code", Integer.toString(resCode));
+        }
+
         logger.info(Thread.currentThread().getName() + ": Required: " + 
started.elapsed(TimeUnit.MILLISECONDS)
             + "ms : Sending 'GET' request to : " + url + " Response: " + 
resCode);
 
diff --git 
a/tcbot-engine/src/main/java/org/apache/ignite/ci/user/TcHelperUser.java 
b/tcbot-engine/src/main/java/org/apache/ignite/ci/user/TcHelperUser.java
index 2cc6be38..3535b384 100644
--- a/tcbot-engine/src/main/java/org/apache/ignite/ci/user/TcHelperUser.java
+++ b/tcbot-engine/src/main/java/org/apache/ignite/ci/user/TcHelperUser.java
@@ -61,6 +61,8 @@ public class TcHelperUser implements IVersionedEntity, 
INotificationChannel {
 
     private Boolean admin;
 
+    public Long adminLastCheckedTs;
+
     /** Subscribed to all failures in following tracked branches. */
     @Nullable private Set<String> subscribedToAllFailures;
 
@@ -93,13 +95,37 @@ public class TcHelperUser implements IVersionedEntity, 
INotificationChannel {
      */
     @Nullable public Credentials getCredentials(String srvId) {
         for (Credentials next : getCredentialsList()) {
-            if (next.serverId.equals(srvId))
+            if (!next.isStale() && next.serverId.equals(srvId))
                 return next;
         }
 
         return null;
     }
 
+    /**
+     * Marks all active credentials as stale.
+     *
+     * @param reason Reason safe to show in UI.
+     */
+    public void markCredentialsStale(String reason) {
+        markCredentialsStale(null, reason);
+    }
+
+    /**
+     * Marks active credentials for one server as stale.
+     *
+     * @param srvId Server id or {@code null} to mark all active credentials.
+     * @param reason Reason safe to show in UI.
+     */
+    public void markCredentialsStale(@Nullable String srvId, String reason) {
+        long now = System.currentTimeMillis();
+
+        for (Credentials next : getCredentialsList()) {
+            if (!next.isStale() && (srvId == null || 
next.serverId.equals(srvId)))
+                next.markStale(now, reason);
+        }
+    }
+
     public List<Credentials> getCredentialsList() {
         if (credentialsList == null)
             credentialsList = new ArrayList<>();
@@ -207,6 +233,23 @@ public class TcHelperUser implements IVersionedEntity, 
INotificationChannel {
         this.admin = admin;
     }
 
+    /**
+     * @param admin Administration.
+     * @param lastCheckedTs Time when TeamCity user groups were successfully 
checked.
+     */
+    public void updateAdmin(Boolean admin, long lastCheckedTs) {
+        this.admin = admin;
+        this.adminLastCheckedTs = lastCheckedTs;
+    }
+
+    /**
+     * @param nowTs Current timestamp.
+     * @param maxAgeMs Max acceptable age.
+     */
+    public boolean isAdminStatusStale(long nowTs, long maxAgeMs) {
+        return adminLastCheckedTs == null || (nowTs - adminLastCheckedTs) > 
maxAgeMs;
+    }
+
     /**
      *
      */
@@ -220,6 +263,12 @@ public class TcHelperUser implements IVersionedEntity, 
INotificationChannel {
 
         byte[] passwordUnderUserKey;
 
+        Boolean stale;
+
+        Long staleTs;
+
+        String staleReason;
+
         Credentials() {
 
         }
@@ -234,6 +283,7 @@ public class TcHelperUser implements IVersionedEntity, 
INotificationChannel {
             return MoreObjects.toStringHelper(this)
                 .add("serverId", serverId)
                 .add("username", username)
+                .add("stale", isStale())
                 .add("passwordUnderUserKey", 
printHexBinary(passwordUnderUserKey))
                 .toString();
         }
@@ -254,6 +304,24 @@ public class TcHelperUser implements IVersionedEntity, 
INotificationChannel {
             return passwordUnderUserKey;
         }
 
+        public boolean isStale() {
+            return Boolean.TRUE.equals(stale);
+        }
+
+        public Long getStaleTs() {
+            return staleTs;
+        }
+
+        public String getStaleReason() {
+            return staleReason;
+        }
+
+        void markStale(long staleTs, String reason) {
+            this.stale = true;
+            this.staleTs = staleTs;
+            this.staleReason = reason;
+        }
+
         public void setPassword(String password, byte[] userKey) {
             setPasswordUnderUserKey(
                 CryptUtil.aesEncryptP5Pad(
diff --git 
a/tcbot-engine/src/main/java/org/apache/ignite/tcbot/engine/conf/ITcBotConfig.java
 
b/tcbot-engine/src/main/java/org/apache/ignite/tcbot/engine/conf/ITcBotConfig.java
index bc28739b..199cc37b 100644
--- 
a/tcbot-engine/src/main/java/org/apache/ignite/tcbot/engine/conf/ITcBotConfig.java
+++ 
b/tcbot-engine/src/main/java/org/apache/ignite/tcbot/engine/conf/ITcBotConfig.java
@@ -22,6 +22,7 @@ import org.apache.ignite.tcbot.common.conf.ITcServerConfig;
 import org.apache.ignite.tcbot.common.conf.IDataSourcesConfigSupplier;
 
 import java.util.Collection;
+import java.util.Collections;
 
 /**
  * Teamcity Bot configuration access interface.
@@ -36,6 +37,9 @@ public interface ITcBotConfig extends 
IDataSourcesConfigSupplier {
     /** Default confidence. */
     Double DEFAULT_CONFIDENCE = 0.95;
 
+    /** Default TeamCity group id whose members are allowed to administer bot 
settings. */
+    String DEFAULT_BOT_ADMIN_GROUP = "IGNITE_COMMITER";
+
     /** */
     String primaryServerCode();
 
@@ -66,6 +70,13 @@ public interface ITcBotConfig extends 
IDataSourcesConfigSupplier {
 
     IGitHubConfig getGitConfig(String srvCode);
 
+    /**
+     * @return TeamCity group keys/names whose members are bot admins.
+     */
+    default Collection<String> botAdminGroups() {
+        return Collections.singleton(DEFAULT_BOT_ADMIN_GROUP);
+    }
+
     /**
      * @return notification settings config.
      */
diff --git 
a/tcbot-engine/src/main/java/org/apache/ignite/tcbot/engine/conf/TcBotJsonConfig.java
 
b/tcbot-engine/src/main/java/org/apache/ignite/tcbot/engine/conf/TcBotJsonConfig.java
index 6f078c2e..9c1c3263 100644
--- 
a/tcbot-engine/src/main/java/org/apache/ignite/tcbot/engine/conf/TcBotJsonConfig.java
+++ 
b/tcbot-engine/src/main/java/org/apache/ignite/tcbot/engine/conf/TcBotJsonConfig.java
@@ -47,6 +47,9 @@ public class TcBotJsonConfig implements 
ITrackedBranchesConfig {
     /** Always failed test detection. */
     @Nullable private Boolean alwaysFailedTestDetection;
 
+    /** TeamCity groups allowed to administer bot settings. */
+    @Nullable private List<String> botAdminGroups;
+
     /** Additional list Servers to be used for validation of PRs, but not for 
tracking any branches. */
     private List<TcServerConfig> tcServers = new ArrayList<>();
 
@@ -115,6 +118,13 @@ public class TcBotJsonConfig implements 
ITrackedBranchesConfig {
         return alwaysFailedTestDetection;
     }
 
+    /**
+     * @return TeamCity groups allowed to administer bot settings.
+     */
+    @Nullable public List<String> botAdminGroups() {
+        return botAdminGroups;
+    }
+
     public Optional<TcServerConfig> getTcConfig(String code) {
         return tcServers.stream().filter(s -> Objects.equals(code, 
s.getCode())).findAny();
     }
diff --git 
a/tcbot-teamcity-ignited/src/main/java/org/apache/ignite/tcignited/TcIgnitedCachingProvider.java
 
b/tcbot-teamcity-ignited/src/main/java/org/apache/ignite/tcignited/TcIgnitedCachingProvider.java
index 5f68b4be..c2abc0c4 100644
--- 
a/tcbot-teamcity-ignited/src/main/java/org/apache/ignite/tcignited/TcIgnitedCachingProvider.java
+++ 
b/tcbot-teamcity-ignited/src/main/java/org/apache/ignite/tcignited/TcIgnitedCachingProvider.java
@@ -76,29 +76,35 @@ class TcIgnitedCachingProvider implements 
ITeamcityIgnitedProvider {
 
         String realSrvCode = !Strings.isNullOrEmpty(ref) && 
!srvCode.equals(ref) ? ref : srvCode;
 
-        String fullKey = Strings.nullToEmpty(prov == null ? null : 
prov.getUser(realSrvCode)) + ":" + Strings.nullToEmpty(realSrvCode);
+        if (prov != null)
+            return createServer(realSrvCode, prov.getUser(realSrvCode), 
prov.getPassword(realSrvCode));
+
+        String fullKey = Strings.nullToEmpty(realSrvCode);
 
         try {
-            return srvs.get(fullKey, () -> {
-                final TeamcityServiceConnection teamcityServiceConnection = 
srvFactory.get();
-                teamcityServiceConnection.init(realSrvCode);
+            return srvs.get(fullKey, () -> createServer(realSrvCode, null, 
null));
+        }
+        catch (ExecutionException e) {
+            throw ExceptionUtil.propagateException(e);
+        }
+    }
 
-                if (prov != null) {
-                    String user = prov.getUser(realSrvCode);
-                    String pwd = prov.getPassword(realSrvCode);
+    /**
+     * @param realSrvCode Resolved server code.
+     * @param user User or {@code null} for configured/anonymous access.
+     * @param pwd Password or {@code null} for configured/anonymous access.
+     */
+    private ITeamcityIgnited createServer(String realSrvCode, @Nullable String 
user, @Nullable String pwd) {
+        final TeamcityServiceConnection teamcityServiceConnection = 
srvFactory.get();
+        teamcityServiceConnection.init(realSrvCode);
 
-                    teamcityServiceConnection.setAuthData(user, pwd);
-                }
+        if (user != null || pwd != null)
+            teamcityServiceConnection.setAuthData(user, pwd);
 
-                TeamcityIgnitedImpl impl = provider.get();
+        TeamcityIgnitedImpl impl = provider.get();
 
-                impl.init(teamcityServiceConnection);
+        impl.init(teamcityServiceConnection);
 
-                return impl;
-            });
-        }
-        catch (ExecutionException e) {
-            throw ExceptionUtil.propagateException(e);
-        }
+        return impl;
     }
 }
diff --git 
a/tcbot-teamcity/src/main/java/org/apache/ignite/tcservice/TeamcityServiceConnection.java
 
b/tcbot-teamcity/src/main/java/org/apache/ignite/tcservice/TeamcityServiceConnection.java
index 84339e3a..870c6554 100644
--- 
a/tcbot-teamcity/src/main/java/org/apache/ignite/tcservice/TeamcityServiceConnection.java
+++ 
b/tcbot-teamcity/src/main/java/org/apache/ignite/tcservice/TeamcityServiceConnection.java
@@ -449,6 +449,14 @@ public class TeamcityServiceConnection implements 
ITeamcity {
         return getJaxbUsingHref("app/rest/latest/users", Users.class);
     }
 
+    /**
+     * @return Current TeamCity user for the configured auth token.
+     */
+    @AutoProfiling
+    public User getCurrentUser() {
+        return getJaxbUsingHref("app/rest/users/current", User.class);
+    }
+
     /** {@inheritDoc} */
     @AutoProfiling
     @Override public User getUserByUsername(String username) {
diff --git 
a/tcbot-teamcity/src/main/java/org/apache/ignite/tcservice/login/ITcLogin.java 
b/tcbot-teamcity/src/main/java/org/apache/ignite/tcservice/login/ITcLogin.java
index 458c094a..c269f94d 100644
--- 
a/tcbot-teamcity/src/main/java/org/apache/ignite/tcservice/login/ITcLogin.java
+++ 
b/tcbot-teamcity/src/main/java/org/apache/ignite/tcservice/login/ITcLogin.java
@@ -30,4 +30,18 @@ public interface ITcLogin {
      * @return user settings on this teamcity
      */
     public User checkServiceUserAndPassword(String srvId, String username, 
String pwd);
+
+    /**
+     * Checks credentials and preserves the difference between an explicit 
authentication failure and a temporarily
+     * unavailable TeamCity server.
+     *
+     * @param srvId Server id.
+     * @param username Username.
+     * @param pwd Password.
+     */
+    public default TcLoginResult checkServiceUserAndPasswordResult(String 
srvId, String username, String pwd) {
+        User user = checkServiceUserAndPassword(srvId, username, pwd);
+
+        return user == null ? TcLoginResult.notChecked() : 
TcLoginResult.accepted(user);
+    }
 }
diff --git 
a/tcbot-teamcity/src/main/java/org/apache/ignite/tcservice/login/TcLoginImpl.java
 
b/tcbot-teamcity/src/main/java/org/apache/ignite/tcservice/login/TcLoginImpl.java
index 1ddc8a66..8e645bb8 100644
--- 
a/tcbot-teamcity/src/main/java/org/apache/ignite/tcservice/login/TcLoginImpl.java
+++ 
b/tcbot-teamcity/src/main/java/org/apache/ignite/tcservice/login/TcLoginImpl.java
@@ -16,6 +16,7 @@
  */
 package org.apache.ignite.tcservice.login;
 
+import com.google.common.base.Strings;
 import org.apache.ignite.tcservice.TeamcityServiceConnection;
 import org.apache.ignite.tcservice.model.user.User;
 import org.apache.ignite.tcbot.common.exeption.ServiceUnauthorizedException;
@@ -37,6 +38,11 @@ public class TcLoginImpl implements ITcLogin {
 
     /** {@inheritDoc} */
     @Override public User checkServiceUserAndPassword(String srvId, String 
username, String pwd) {
+        return checkServiceUserAndPasswordResult(srvId, username, pwd).user();
+    }
+
+    /** {@inheritDoc} */
+    @Override public TcLoginResult checkServiceUserAndPasswordResult(String 
srvId, String username, String pwd) {
         try {
             TeamcityServiceConnection tcConn = tcFactory.get();
 
@@ -44,25 +50,32 @@ public class TcLoginImpl implements ITcLogin {
 
             tcConn.setAuthData(username, pwd);
 
-            final User tcUser = tcConn.getUserByUsername(username);
+            final User tcUser = tcConn.getCurrentUser();
+
+            if (tcUser != null) {
+                if (!Strings.isNullOrEmpty(tcUser.username) && 
!username.equalsIgnoreCase(tcUser.username)) {
+                    logger.warn("TC current user mismatch [requested={}, 
returned={}]", username, tcUser.username);
+
+                    return TcLoginResult.unauthorized();
+                }
 
-            if (tcUser != null)
                 logger.info("TC user returned: " + tcUser);
+            }
 
-            return tcUser;
+            return tcUser == null ? TcLoginResult.notChecked() : 
TcLoginResult.accepted(tcUser);
         }
         catch (ServiceUnauthorizedException e) {
             final String msg = "Service " + srvId + " rejected credentials 
from " + username;
             System.err.println(msg);
 
             logger.warn(msg, e);
-            return null;
+            return TcLoginResult.unauthorized();
         }
         catch (Exception e) {
             e.printStackTrace();
             logger.error("Unexpected login exception", e);
 
-            return null;
+            return TcLoginResult.notChecked();
         }
     }
 }
diff --git 
a/tcbot-teamcity/src/main/java/org/apache/ignite/tcservice/login/TcLoginResult.java
 
b/tcbot-teamcity/src/main/java/org/apache/ignite/tcservice/login/TcLoginResult.java
new file mode 100644
index 00000000..15559755
--- /dev/null
+++ 
b/tcbot-teamcity/src/main/java/org/apache/ignite/tcservice/login/TcLoginResult.java
@@ -0,0 +1,62 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.ignite.tcservice.login;
+
+import javax.annotation.Nullable;
+import org.apache.ignite.tcservice.model.user.User;
+
+/**
+ * TeamCity login check result.
+ */
+public class TcLoginResult {
+    /** TeamCity accepted the supplied credentials. */
+    public static TcLoginResult accepted(User user) {
+        return new TcLoginResult(user, false);
+    }
+
+    /** TeamCity explicitly rejected the supplied credentials. */
+    public static TcLoginResult unauthorized() {
+        return new TcLoginResult(null, true);
+    }
+
+    /** TeamCity could not be checked, for example because it is temporarily 
unavailable. */
+    public static TcLoginResult notChecked() {
+        return new TcLoginResult(null, false);
+    }
+
+    @Nullable private final User user;
+
+    private final boolean unauthorized;
+
+    private TcLoginResult(@Nullable User user, boolean unauthorized) {
+        this.user = user;
+        this.unauthorized = unauthorized;
+    }
+
+    @Nullable public User user() {
+        return user;
+    }
+
+    public boolean accepted() {
+        return user != null;
+    }
+
+    public boolean isUnauthorized() {
+        return unauthorized;
+    }
+}
diff --git 
a/tcbot-teamcity/src/main/java/org/apache/ignite/tcservice/login/ITcLogin.java 
b/tcbot-teamcity/src/main/java/org/apache/ignite/tcservice/model/user/GroupRef.java
similarity index 60%
copy from 
tcbot-teamcity/src/main/java/org/apache/ignite/tcservice/login/ITcLogin.java
copy to 
tcbot-teamcity/src/main/java/org/apache/ignite/tcservice/model/user/GroupRef.java
index 458c094a..2e135fd0 100644
--- 
a/tcbot-teamcity/src/main/java/org/apache/ignite/tcservice/login/ITcLogin.java
+++ 
b/tcbot-teamcity/src/main/java/org/apache/ignite/tcservice/model/user/GroupRef.java
@@ -14,20 +14,32 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-package org.apache.ignite.tcservice.login;
 
-import org.apache.ignite.tcservice.model.user.User;
+package org.apache.ignite.tcservice.model.user;
+
+import javax.xml.bind.annotation.XmlAttribute;
 
 /**
- * Teamcity Login implementation.
+ * TeamCity user group reference.
  */
-public interface ITcLogin {
+public class GroupRef {
+    @XmlAttribute public String key;
+    @XmlAttribute public String id;
+    @XmlAttribute public String name;
+    @XmlAttribute public String href;
+
+    /**
+     * Required by JAXB.
+     */
+    public GroupRef() {
+    }
+
     /**
-     * Check if user has correct credentials to particular server.
-     * @param srvId Server id.
-     * @param username Username.
-     * @param pwd Password.
-     * @return user settings on this teamcity
+     * @param key Group key.
+     * @param name Group display name.
      */
-    public User checkServiceUserAndPassword(String srvId, String username, 
String pwd);
+    public GroupRef(String key, String name) {
+        this.key = key;
+        this.name = name;
+    }
 }
diff --git 
a/tcbot-teamcity/src/main/java/org/apache/ignite/tcservice/model/user/User.java 
b/tcbot-teamcity/src/main/java/org/apache/ignite/tcservice/model/user/Groups.java
similarity index 59%
copy from 
tcbot-teamcity/src/main/java/org/apache/ignite/tcservice/model/user/User.java
copy to 
tcbot-teamcity/src/main/java/org/apache/ignite/tcservice/model/user/Groups.java
index 37efa893..02a498fe 100644
--- 
a/tcbot-teamcity/src/main/java/org/apache/ignite/tcservice/model/user/User.java
+++ 
b/tcbot-teamcity/src/main/java/org/apache/ignite/tcservice/model/user/Groups.java
@@ -17,32 +17,38 @@
 
 package org.apache.ignite.tcservice.model.user;
 
-
-import org.apache.ignite.tcservice.model.conf.bt.Parameters;
-
-import javax.annotation.Nullable;
-import javax.xml.bind.annotation.XmlAttribute;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
 import javax.xml.bind.annotation.XmlElement;
-import javax.xml.bind.annotation.XmlRootElement;
 
-@XmlRootElement(name = "user")
-public class User extends UserRef {
-    @XmlAttribute
-    public String email;
-    @XmlAttribute
-    public String lastLogin;
+/**
+ * TeamCity user groups.
+ */
+public class Groups {
+    @XmlElement(name = "group")
+    private List<GroupRef> groups;
+
+    /**
+     * Required by JAXB.
+     */
+    public Groups() {
+    }
 
-    @XmlElement(name = "parameters")
-    Parameters parameters;
+    /**
+     * @param groups Groups.
+     */
+    public Groups(GroupRef... groups) {
+        this.groups = Arrays.asList(groups);
+    }
 
     /**
-     * @return space separated list of vcs user names
+     * @return Group references.
      */
-    @Nullable
-    String getVcsNames() {
-        if (parameters == null)
-            return null;
+    public List<GroupRef> getGroupRefs() {
+        if (groups == null)
+            return Collections.emptyList();
 
-        return parameters.getParameter("plugin:vcs:anyVcs:anyVcsRoot");
+        return groups;
     }
 }
diff --git 
a/tcbot-teamcity/src/main/java/org/apache/ignite/tcservice/model/user/User.java 
b/tcbot-teamcity/src/main/java/org/apache/ignite/tcservice/model/user/User.java
index 37efa893..9e06d45b 100644
--- 
a/tcbot-teamcity/src/main/java/org/apache/ignite/tcservice/model/user/User.java
+++ 
b/tcbot-teamcity/src/main/java/org/apache/ignite/tcservice/model/user/User.java
@@ -18,6 +18,7 @@
 package org.apache.ignite.tcservice.model.user;
 
 
+import java.util.Collection;
 import org.apache.ignite.tcservice.model.conf.bt.Parameters;
 
 import javax.annotation.Nullable;
@@ -35,6 +36,9 @@ public class User extends UserRef {
     @XmlElement(name = "parameters")
     Parameters parameters;
 
+    @XmlElement(name = "groups")
+    private Groups groups;
+
     /**
      * @return space separated list of vcs user names
      */
@@ -45,4 +49,33 @@ public class User extends UserRef {
 
         return parameters.getParameter("plugin:vcs:anyVcs:anyVcsRoot");
     }
+
+    /**
+     * @param groups Groups.
+     */
+    public void setGroups(Groups groups) {
+        this.groups = groups;
+    }
+
+    /**
+     * @param groupIds TeamCity group ids.
+     */
+    public boolean belongsToAnyGroup(Collection<String> groupIds) {
+        if (groups == null || groupIds == null || groupIds.isEmpty())
+            return false;
+
+        return groups.getGroupRefs().stream().anyMatch(grp ->
+            groupIds.stream().anyMatch(configured -> 
matchesGroupId(configured, grp.key)));
+    }
+
+    /**
+     * @param configured Configured group id.
+     * @param actualId Actual group id returned by TeamCity as a REST key.
+     */
+    private boolean matchesGroupId(String configured, String actualId) {
+        if (configured == null || actualId == null)
+            return false;
+
+        return configured.trim().equals(actualId.trim());
+    }
 }

Reply via email to