]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-16263 Update API endpoint for token generation
authorMatteo Mara <matteo.mara@sonarsource.com>
Wed, 20 Apr 2022 13:58:32 +0000 (15:58 +0200)
committersonartech <sonartech@sonarsource.com>
Fri, 29 Apr 2022 20:03:18 +0000 (20:03 +0000)
server/sonar-webserver-webapi/src/main/java/org/sonar/server/usertoken/ws/GenerateAction.java
server/sonar-webserver-webapi/src/main/java/org/sonar/server/usertoken/ws/UserTokenSupport.java
server/sonar-webserver-webapi/src/test/java/org/sonar/server/usertoken/ws/GenerateActionTest.java
sonar-ws/src/main/protobuf/ws-user_tokens.proto

index 5573d1e21bb382a315068174b968825788f433c0..79992ae442fe689f3d24481cabcf61f0847ff513 100644 (file)
@@ -19,6 +19,8 @@
  */
 package org.sonar.server.usertoken.ws;
 
+import java.util.Optional;
+import javax.annotation.Nullable;
 import org.sonar.api.server.ws.Request;
 import org.sonar.api.server.ws.Response;
 import org.sonar.api.server.ws.WebService;
@@ -29,16 +31,22 @@ import org.sonar.db.user.UserDto;
 import org.sonar.db.user.UserTokenDto;
 import org.sonar.server.exceptions.ServerException;
 import org.sonar.server.usertoken.TokenGenerator;
+import org.sonar.server.usertoken.TokenType;
 import org.sonarqube.ws.UserTokens;
 import org.sonarqube.ws.UserTokens.GenerateWsResponse;
 
+import static com.google.common.base.Preconditions.checkArgument;
 import static java.net.HttpURLConnection.HTTP_INTERNAL_ERROR;
 import static org.sonar.api.utils.DateUtils.formatDateTime;
 import static org.sonar.server.exceptions.BadRequestException.checkRequest;
+import static org.sonar.server.usertoken.TokenType.GLOBAL_ANALYSIS_TOKEN;
+import static org.sonar.server.usertoken.TokenType.PROJECT_ANALYSIS_TOKEN;
 import static org.sonar.server.usertoken.TokenType.USER_TOKEN;
 import static org.sonar.server.usertoken.ws.UserTokenSupport.ACTION_GENERATE;
 import static org.sonar.server.usertoken.ws.UserTokenSupport.PARAM_LOGIN;
 import static org.sonar.server.usertoken.ws.UserTokenSupport.PARAM_NAME;
+import static org.sonar.server.usertoken.ws.UserTokenSupport.PARAM_PROJECT_KEY;
+import static org.sonar.server.usertoken.ws.UserTokenSupport.PARAM_TYPE;
 import static org.sonar.server.ws.WsUtils.writeProtobuf;
 
 public class GenerateAction implements UserTokensWsAction {
@@ -77,6 +85,16 @@ public class GenerateAction implements UserTokensWsAction {
       .setMaximumLength(MAX_TOKEN_NAME_LENGTH)
       .setDescription("Token name")
       .setExampleValue("Project scan on Travis");
+
+    action.createParam(PARAM_TYPE)
+      .setSince("9.5")
+      .setDescription("Token Type. If this parameters is set to " + PROJECT_ANALYSIS_TOKEN.name() + ", it is necessary to provide the projectKey parameter too.")
+      .setPossibleValues(USER_TOKEN.name(), GLOBAL_ANALYSIS_TOKEN.name(), PROJECT_ANALYSIS_TOKEN.name())
+      .setDefaultValue(USER_TOKEN.name());
+
+    action.createParam(PARAM_PROJECT_KEY)
+      .setSince("9.5")
+      .setDescription("The key of the only project that can be analyzed by the " + PROJECT_ANALYSIS_TOKEN.name() + " being generated.");
   }
 
   @Override
@@ -91,13 +109,52 @@ public class GenerateAction implements UserTokensWsAction {
       UserDto user = userTokenSupport.getUser(dbSession, request);
       checkTokenDoesNotAlreadyExists(dbSession, user, name);
 
-      String token = tokenGenerator.generate(USER_TOKEN);
+      String token = generateToken(request, dbSession);
       String tokenHash = hashToken(dbSession, token);
-      UserTokenDto userTokenDto = insertTokenInDb(dbSession, user, name, tokenHash);
+      String projectKey = getProjecKeyFromRequest(request).orElse(null);
+      UserTokenDto userTokenDto = insertTokenInDb(dbSession, user, name, tokenHash, getTokenTypeFromRequest(request), projectKey);
       return buildResponse(userTokenDto, token, user);
     }
   }
 
+  private String generateToken(Request request, DbSession dbSession) {
+    TokenType tokenType = getTokenTypeFromRequest(request);
+    validateParametersCombination(dbSession, request, tokenType);
+    return tokenGenerator.generate(tokenType);
+  }
+
+  private void validateParametersCombination(DbSession dbSession, Request request, TokenType tokenType) {
+    if (PROJECT_ANALYSIS_TOKEN.equals(tokenType)) {
+      validateProjectAnalysisParameters(dbSession, request);
+    } else if (GLOBAL_ANALYSIS_TOKEN.equals(tokenType)) {
+      validateGlobalAnalysisParameters(request);
+    }
+  }
+
+  private void validateProjectAnalysisParameters(DbSession dbSession, Request request) {
+    checkArgument(userTokenSupport.sameLoginAsConnectedUser(request), "A Project Analysis Token cannot be generated for another user.");
+    checkArgument(request.param(PARAM_PROJECT_KEY) != null, "A projectKey is needed when creating Project Analysis Token");
+    userTokenSupport.validateProjectScanPermission(dbSession, getProjecKeyFromRequest(request).orElse(""));
+  }
+
+  private void validateGlobalAnalysisParameters(Request request) {
+    checkArgument(userTokenSupport.sameLoginAsConnectedUser(request), "A Global Analysis Token cannot be generated for another user.");
+    userTokenSupport.validateGlobalScanPermission();
+  }
+
+  private static Optional<String> getProjecKeyFromRequest(Request request) {
+    String projectKey = null;
+    if (PROJECT_ANALYSIS_TOKEN.equals(getTokenTypeFromRequest(request))) {
+      projectKey = request.mandatoryParam(PARAM_PROJECT_KEY).trim();
+    }
+    return Optional.ofNullable(projectKey);
+  }
+
+  private static TokenType getTokenTypeFromRequest(Request request) {
+    String tokenTypeValue = request.mandatoryParam(PARAM_TYPE).trim();
+    return TokenType.valueOf(tokenTypeValue);
+  }
+
   private String hashToken(DbSession dbSession, String token) {
     String tokenHash = tokenGenerator.hash(token);
     UserTokenDto userToken = dbClient.userTokenDao().selectByTokenHash(dbSession, tokenHash);
@@ -112,26 +169,36 @@ public class GenerateAction implements UserTokensWsAction {
     checkRequest(userTokenDto == null, "A user token for login '%s' and name '%s' already exists", user.getLogin(), name);
   }
 
-  private UserTokenDto insertTokenInDb(DbSession dbSession, UserDto user, String name, String tokenHash) {
+  private UserTokenDto insertTokenInDb(DbSession dbSession, UserDto user, String name, String tokenHash, TokenType tokenType, @Nullable String projectKey) {
     UserTokenDto userTokenDto = new UserTokenDto()
       .setUserUuid(user.getUuid())
       .setName(name)
       .setTokenHash(tokenHash)
       .setCreatedAt(system.now())
-      .setType(USER_TOKEN.name());
+      .setType(tokenType.name());
+
+    if (projectKey != null) {
+      userTokenDto.setProjectKey(projectKey);
+    }
+
     dbClient.userTokenDao().insert(dbSession, userTokenDto, user.getLogin());
     dbSession.commit();
     return userTokenDto;
   }
 
   private static GenerateWsResponse buildResponse(UserTokenDto userTokenDto, String token, UserDto user) {
-    return UserTokens.GenerateWsResponse.newBuilder()
+    GenerateWsResponse.Builder responseBuilder = GenerateWsResponse.newBuilder()
       .setLogin(user.getLogin())
       .setName(userTokenDto.getName())
       .setCreatedAt(formatDateTime(userTokenDto.getCreatedAt()))
       .setToken(token)
-      .setType(userTokenDto.getType())
-      .build();
+      .setType(userTokenDto.getType());
+
+    if (userTokenDto.getProjectKey() != null) {
+      responseBuilder.setProjectKey(userTokenDto.getProjectKey());
+    }
+
+    return responseBuilder.build();
   }
 
 }
index 9cb34114792898bf6ab6fde4724b8758e4179c7d..118d9014086fc0cb22207dbc26f0268a235d45f0 100644 (file)
  */
 package org.sonar.server.usertoken.ws;
 
+import java.util.Optional;
 import javax.annotation.Nullable;
 import org.sonar.api.server.ws.Request;
+import org.sonar.api.web.UserRole;
 import org.sonar.db.DbClient;
 import org.sonar.db.DbSession;
+import org.sonar.db.project.ProjectDto;
 import org.sonar.db.user.UserDto;
+import org.sonar.server.exceptions.NotFoundException;
 import org.sonar.server.user.UserSession;
 
+import static java.lang.String.format;
 import static java.util.Objects.requireNonNull;
-import static org.sonar.server.user.AbstractUserSession.insufficientPrivilegesException;
+import static org.sonar.db.permission.GlobalPermission.SCAN;
 import static org.sonar.server.exceptions.NotFoundException.checkFound;
+import static org.sonar.server.user.AbstractUserSession.insufficientPrivilegesException;
 
 public class UserTokenSupport {
 
@@ -38,6 +44,8 @@ public class UserTokenSupport {
   static final String ACTION_GENERATE = "generate";
   static final String PARAM_LOGIN = "login";
   static final String PARAM_NAME = "name";
+  static final String PARAM_TYPE = "type";
+  static final String PARAM_PROJECT_KEY = "projectKey";
 
   private final DbClient dbClient;
   private final UserSession userSession;
@@ -56,6 +64,10 @@ public class UserTokenSupport {
     return user;
   }
 
+  boolean sameLoginAsConnectedUser(Request request) {
+    return request.param(PARAM_LOGIN) == null || isLoggedInUser(userSession, request.param(PARAM_LOGIN));
+  }
+
   private static void validate(UserSession userSession, @Nullable String requestLogin) {
     userSession.checkLoggedIn();
     if (userSession.isSystemAdministrator() || isLoggedInUser(userSession, requestLogin)) {
@@ -67,4 +79,26 @@ public class UserTokenSupport {
   private static boolean isLoggedInUser(UserSession userSession, @Nullable String requestLogin) {
     return requestLogin != null && requestLogin.equals(userSession.getLogin());
   }
+
+  public void validateGlobalScanPermission() {
+    if (userSession.hasPermission(SCAN)){
+      return;
+    }
+    throw insufficientPrivilegesException();
+  }
+
+  public void validateProjectScanPermission(DbSession dbSession, String projecKeyFromRequest) {
+    Optional<ProjectDto> projectDto = dbClient.projectDao().selectProjectByKey(dbSession, projecKeyFromRequest);
+    if (projectDto.isEmpty()) {
+      throw new NotFoundException(format("Project key '%s' not found", projecKeyFromRequest));
+    }
+    validateProjectScanPermission(projectDto.get());
+  }
+
+  private void validateProjectScanPermission(ProjectDto projectDto) {
+    if (userSession.hasProjectPermission(UserRole.SCAN, projectDto) || userSession.hasPermission(SCAN)) {
+      return;
+    }
+    throw insufficientPrivilegesException();
+  }
 }
index d000a55914ca205f7aa531acb320d7b720275e7b..3928d32c23de7d3cb8dfb5cde238ffc5b76790ef 100644 (file)
@@ -26,6 +26,7 @@ import org.junit.Test;
 import org.sonar.api.server.ws.WebService;
 import org.sonar.api.utils.System2;
 import org.sonar.db.DbTester;
+import org.sonar.db.component.ComponentDto;
 import org.sonar.db.user.UserDto;
 import org.sonar.server.exceptions.BadRequestException;
 import org.sonar.server.exceptions.ForbiddenException;
@@ -45,8 +46,14 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy;
 import static org.mockito.ArgumentMatchers.anyString;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.when;
+import static org.sonar.db.permission.GlobalPermission.SCAN;
+import static org.sonar.server.usertoken.TokenType.GLOBAL_ANALYSIS_TOKEN;
+import static org.sonar.server.usertoken.TokenType.PROJECT_ANALYSIS_TOKEN;
+import static org.sonar.server.usertoken.TokenType.USER_TOKEN;
 import static org.sonar.server.usertoken.ws.UserTokenSupport.PARAM_LOGIN;
 import static org.sonar.server.usertoken.ws.UserTokenSupport.PARAM_NAME;
+import static org.sonar.server.usertoken.ws.UserTokenSupport.PARAM_PROJECT_KEY;
+import static org.sonar.server.usertoken.ws.UserTokenSupport.PARAM_TYPE;
 import static org.sonar.test.JsonAssert.assertJson;
 
 public class GenerateActionTest {
@@ -58,14 +65,16 @@ public class GenerateActionTest {
   @Rule
   public UserSessionRule userSession = UserSessionRule.standalone();
 
-  private TokenGenerator tokenGenerator = mock(TokenGenerator.class);
+  private final TokenGenerator tokenGenerator = mock(TokenGenerator.class);
 
-  private WsActionTester ws = new WsActionTester(
+  private final WsActionTester ws = new WsActionTester(
     new GenerateAction(db.getDbClient(), System2.INSTANCE, tokenGenerator, new UserTokenSupport(db.getDbClient(), userSession)));
 
   @Before
   public void setUp() {
-    when(tokenGenerator.generate(TokenType.USER_TOKEN)).thenReturn("123456789");
+    when(tokenGenerator.generate(USER_TOKEN)).thenReturn("123456789");
+    when(tokenGenerator.generate(GLOBAL_ANALYSIS_TOKEN)).thenReturn("sqa_123456789");
+    when(tokenGenerator.generate(PROJECT_ANALYSIS_TOKEN)).thenReturn("sqp_123456789");
     when(tokenGenerator.hash(anyString())).thenReturn("987654321");
   }
 
@@ -79,12 +88,16 @@ public class GenerateActionTest {
     assertThat(action.isPost()).isTrue();
     assertThat(action.param("login").isRequired()).isFalse();
     assertThat(action.param("name").isRequired()).isTrue();
+    assertThat(action.param("type").isRequired()).isFalse();
+    assertThat(action.param("type").since()).isEqualTo("9.5");
+    assertThat(action.param("projectKey").isRequired()).isFalse();
+    assertThat(action.param("projectKey").since()).isEqualTo("9.5");
+
   }
 
   @Test
   public void json_example() {
     UserDto user1 = db.users().insertUser(u -> u.setLogin("grace.hopper"));
-    UserDto user2 = db.users().insertUser(u -> u.setLogin("ada.lovelace"));
     logInAsSystemAdministrator();
 
     String response = ws.newRequest()
@@ -107,13 +120,69 @@ public class GenerateActionTest {
     assertThat(response.getCreatedAt()).isNotEmpty();
   }
 
+  @Test
+  public void a_user_can_generate_globalAnalysisToken_with_the_global_scan_permission() {
+    UserDto user = db.users().insertUser();
+    userSession.logIn(user);
+    userSession.addPermission(SCAN);
+
+    GenerateWsResponse response = newRequest(null, TOKEN_NAME, GLOBAL_ANALYSIS_TOKEN, null);
+
+    assertThat(response.getLogin()).isEqualTo(user.getLogin());
+    assertThat(response.getToken()).startsWith("sqa_");
+    assertThat(response.getCreatedAt()).isNotEmpty();
+  }
+
+  @Test
+  public void a_user_can_generate_projectAnalysisToken_with_the_project_global_scan_permission() {
+    UserDto user = db.users().insertUser();
+    ComponentDto project = db.components().insertPublicProject();
+    userSession.logIn(user);
+    userSession.addPermission(SCAN);
+
+    GenerateWsResponse response = newRequest(null, TOKEN_NAME, PROJECT_ANALYSIS_TOKEN, project.getKey());
+
+    assertThat(response.getLogin()).isEqualTo(user.getLogin());
+    assertThat(response.getToken()).startsWith("sqp_");
+    assertThat(response.getProjectKey()).isEqualTo(project.getKey());
+    assertThat(response.getCreatedAt()).isNotEmpty();
+  }
+
+  @Test
+  public void a_user_can_generate_projectAnalysisToken_with_the_project_scan_permission() {
+    UserDto user = db.users().insertUser();
+    ComponentDto project = db.components().insertPublicProject();
+    userSession.logIn(user);
+    userSession.addProjectPermission(SCAN.toString(), project);
+
+    GenerateWsResponse response = newRequest(null, TOKEN_NAME, PROJECT_ANALYSIS_TOKEN, project.getKey());
+
+    assertThat(response.getLogin()).isEqualTo(user.getLogin());
+    assertThat(response.getToken()).startsWith("sqp_");
+    assertThat(response.getProjectKey()).isEqualTo(project.getKey());
+    assertThat(response.getCreatedAt()).isNotEmpty();
+  }
+
+  @Test
+  public void a_user_can_generate_projectAnalysisToken_with_the_project_scan_permission_passing_login() {
+    UserDto user = db.users().insertUser();
+    ComponentDto project = db.components().insertPublicProject();
+    userSession.logIn(user);
+    userSession.addProjectPermission(SCAN.toString(), project);
+
+    GenerateWsResponse responseWithLogin = newRequest(user.getLogin(), TOKEN_NAME, PROJECT_ANALYSIS_TOKEN, project.getKey());
+
+    assertThat(responseWithLogin.getLogin()).isEqualTo(user.getLogin());
+    assertThat(responseWithLogin.getToken()).startsWith("sqp_");
+    assertThat(responseWithLogin.getProjectKey()).isEqualTo(project.getKey());
+    assertThat(responseWithLogin.getCreatedAt()).isNotEmpty();
+  }
+
   @Test
   public void fail_if_login_does_not_exist() {
     logInAsSystemAdministrator();
 
-    assertThatThrownBy(() -> {
-      newRequest("unknown-login", "any-name");
-    })
+    assertThatThrownBy(() -> newRequest("unknown-login", "any-name"))
       .isInstanceOf(NotFoundException.class)
       .hasMessage("User with login 'unknown-login' doesn't exist");
   }
@@ -123,21 +192,92 @@ public class GenerateActionTest {
     UserDto user = db.users().insertUser();
     logInAsSystemAdministrator();
 
+    String login = user.getLogin();
+
+    assertThatThrownBy(() -> newRequest(login, "   "))
+      .isInstanceOf(IllegalArgumentException.class)
+      .hasMessage("The 'name' parameter is missing");
+  }
+
+  @Test
+  public void fail_if_globalAnalysisToken_created_for_other_user() {
+    UserDto user = db.users().insertUser();
+    String login = user.getLogin();
+    logInAsSystemAdministrator();
+
+    assertThatThrownBy(() -> newRequest(login, "token 1", GLOBAL_ANALYSIS_TOKEN, null))
+      .isInstanceOf(IllegalArgumentException.class)
+      .hasMessage("A Global Analysis Token cannot be generated for another user.");
+  }
+
+  @Test
+  public void fail_if_projectAnalysisToken_created_for_other_user() {
+    UserDto user = db.users().insertUser();
+    String login = user.getLogin();
+    logInAsSystemAdministrator();
+
+    assertThatThrownBy(() -> newRequest(login, "token 1", PROJECT_ANALYSIS_TOKEN, null))
+      .isInstanceOf(IllegalArgumentException.class)
+      .hasMessage("A Project Analysis Token cannot be generated for another user.");
+  }
+
+  @Test
+  public void fail_if_globalAnalysisToken_created_without_global_permission() {
+    UserDto user = db.users().insertUser();
+    userSession.logIn(user);
+
     assertThatThrownBy(() -> {
-      newRequest(user.getLogin(), "   ");
+      newRequest(null, "token 1", GLOBAL_ANALYSIS_TOKEN, null);
+    })
+      .isInstanceOf(ForbiddenException.class)
+      .hasMessage("Insufficient privileges");
+  }
+
+  @Test
+  public void fail_if_projectAnalysisToken_created_without_project_permission() {
+    UserDto user = db.users().insertUser();
+    userSession.logIn(user);
+    String projectKey = db.components().insertPublicProject().getKey();
+
+    assertThatThrownBy(() -> newRequest(null, "token 1", PROJECT_ANALYSIS_TOKEN, projectKey))
+      .isInstanceOf(ForbiddenException.class)
+      .hasMessage("Insufficient privileges");
+  }
+
+  @Test
+  public void fail_if_projectAnalysisToken_created_for_blank_projectKey() {
+    UserDto user = db.users().insertUser();
+    userSession.logIn(user);
+
+    assertThatThrownBy(() -> {
+      newRequest(null, "token 1", PROJECT_ANALYSIS_TOKEN, null);
     })
       .isInstanceOf(IllegalArgumentException.class)
-      .hasMessage("The 'name' parameter is missing");
+      .hasMessage("A projectKey is needed when creating Project Analysis Token");
+  }
+
+  @Test
+  public void fail_if_projectAnalysisToken_created_for_non_existing_project() {
+    UserDto user = db.users().insertUser();
+    userSession.logIn(user);
+    userSession.addPermission(SCAN);
+
+    assertThatThrownBy(() -> {
+      newRequest(null, "token 1", PROJECT_ANALYSIS_TOKEN, "nonExistingProjectKey");
+    })
+      .isInstanceOf(NotFoundException.class)
+      .hasMessage("Project key 'nonExistingProjectKey' not found");
   }
 
   @Test
   public void fail_if_token_with_same_login_and_name_exists() {
     UserDto user = db.users().insertUser();
+    String login = user.getLogin();
     logInAsSystemAdministrator();
     db.users().insertToken(user, t -> t.setName(TOKEN_NAME));
 
     assertThatThrownBy(() -> {
-      newRequest(user.getLogin(), TOKEN_NAME);
+      newRequest(login, TOKEN_NAME);
     })
       .isInstanceOf(BadRequestException.class)
       .hasMessage(String.format("A user token for login '%s' and name 'Third Party Application' already exists", user.getLogin()));
@@ -146,12 +286,13 @@ public class GenerateActionTest {
   @Test
   public void fail_if_token_hash_already_exists_in_db() {
     UserDto user = db.users().insertUser();
+    String login = user.getLogin();
     logInAsSystemAdministrator();
     when(tokenGenerator.hash(anyString())).thenReturn("987654321");
     db.users().insertToken(user, t -> t.setTokenHash("987654321"));
 
     assertThatThrownBy(() -> {
-      newRequest(user.getLogin(), TOKEN_NAME);
+      newRequest(login, TOKEN_NAME);
     })
       .isInstanceOf(ServerException.class)
       .hasMessage("Error while generating token. Please try again.");
@@ -159,22 +300,22 @@ public class GenerateActionTest {
 
   @Test
   public void throw_ForbiddenException_if_non_administrator_creates_token_for_someone_else() {
-    UserDto user = db.users().insertUser();
+    String login = db.users().insertUser().getLogin();
     userSession.logIn().setNonSystemAdministrator();
 
     assertThatThrownBy(() -> {
-      newRequest(user.getLogin(), TOKEN_NAME);
+      newRequest(login, TOKEN_NAME);
     })
       .isInstanceOf(ForbiddenException.class);
   }
 
   @Test
   public void throw_UnauthorizedException_if_not_logged_in() {
-    UserDto user = db.users().insertUser();
+    String login = db.users().insertUser().getLogin();
     userSession.anonymous();
 
     assertThatThrownBy(() -> {
-      newRequest(user.getLogin(), TOKEN_NAME);
+      newRequest(login, TOKEN_NAME);
     })
       .isInstanceOf(UnauthorizedException.class);
   }
@@ -189,6 +330,20 @@ public class GenerateActionTest {
     return testRequest.executeProtobuf(GenerateWsResponse.class);
   }
 
+  private GenerateWsResponse newRequest(@Nullable String login, String name, TokenType tokenType, @Nullable String projectKey) {
+    TestRequest testRequest = ws.newRequest()
+      .setParam(PARAM_NAME, name)
+      .setParam(PARAM_TYPE, tokenType.toString());
+    if (login != null) {
+      testRequest.setParam(PARAM_LOGIN, login);
+    }
+    if (projectKey != null) {
+      testRequest.setParam(PARAM_PROJECT_KEY, projectKey);
+    }
+
+    return testRequest.executeProtobuf(GenerateWsResponse.class);
+  }
+
   private void logInAsSystemAdministrator() {
     userSession.logIn().setSystemAdministrator();
   }
index 8fc24cd03bc9be23ef3ef320fe5276bc2ef80cb4..ead834a91d74dab0f2313631716bbf850fa2ebba 100644 (file)
@@ -31,6 +31,7 @@ message GenerateWsResponse {
   optional string token = 3;
   optional string createdAt = 4;
   optional string type = 5;
+  optional string projectKey = 6;
 }
 
 // WS api/user_tokens/search