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, "&")
+ .replace(/</g, "<")
+ .replace(/>/g, ">")
+ .replace(/"/g, """)
+ .replace(/'/g, "'");
+}
+
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());
+ }
}