Skip to content

Commit 2be52f0

Browse files
Feature: enable app registration (#703)
<!-- Please provide brief information about the PR, what it contains & its purpose, new behaviors after the change. And let us know here if you need any help: https://github.com/microsoft/HydraLab/issues/new --> ## Description - Enable app registration auth approach for pipeline requests, according to SFI requirement. - Add portal UI for app registration client ID linking: - <img width="2552" height="1305" alt="image" src="https://github.com/user-attachments/assets/23f0f815-a5db-438b-88d8-facb4a5c3d7e" /> <!-- A few words to explain your changes --> ### Linked GitHub issue ID: # ## Pull Request Checklist <!-- Put an x in the boxes that apply. This is simply a reminder of what we are going to look for before merging your code. --> - [X] Tests for the changes have been added (for bug fixes / features) - [X] Code compiles correctly with all tests are passed. - [X] I've read the [contributing guide](https://github.com/microsoft/HydraLab/blob/main/CONTRIBUTING.md#making-changes-to-the-code) and followed the recommended practices. - [ ] [Wikis](https://github.com/microsoft/HydraLab/wiki) or [README](https://github.com/microsoft/HydraLab/blob/main/README.md) have been reviewed and added / updated if needed (for bug fixes / features) ### Does this introduce a breaking change? *If this introduces a breaking change for Hydra Lab users, please describe the impact and migration path.* The pipeline needs to switch to App registration token authentication approach, basic additional steps: - Generate ADO Service Connection for App Registration automatically - Add `API permission` for resource api://228c4d51-5002-4fcf-8752-552b478fff77 in the App Registration - Onboard AR client ID on Hydra Lab portal and create relationship to team ID - Add precedent AAD token acquiring task in test pipeline - [X] Yes - [ ] No ## How you tested it *Please make sure the change is tested, you can test it by adding UTs, do local test and share the screenshots, etc.* Locally tested. Please check the type of change your PR introduces: - [ ] Bugfix - [ ] Feature - [ ] Technical design - [ ] Build related changes - [ ] Refactoring (no functional changes, no api changes) - [ ] Code style update (formatting, renaming) or Documentation content changes - [ ] Other (please describe): ### Feature UI screenshots or Technical design diagrams *If this is a relatively large or complex change, kick it off by drawing the tech design with PlantUML and explaining why you chose the solution you did and what alternatives you considered, etc...* --------- Co-authored-by: MaX ES Bot <mmxesbot@microsoft.com>
1 parent 1c83b53 commit 2be52f0

11 files changed

Lines changed: 597 additions & 17 deletions

File tree

center/src/main/java/com/microsoft/hydralab/center/controller/UserTeamController.java renamed to center/src/main/java/com/microsoft/hydralab/center/controller/TeamController.java

Lines changed: 72 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,16 @@
66
import com.microsoft.hydralab.center.service.SecurityUserService;
77
import com.microsoft.hydralab.center.service.SysTeamService;
88
import com.microsoft.hydralab.center.service.SysUserService;
9+
import com.microsoft.hydralab.center.service.TeamAppManagementService;
910
import com.microsoft.hydralab.center.service.UserTeamManagementService;
1011
import com.microsoft.hydralab.common.entity.agent.Result;
1112
import com.microsoft.hydralab.common.entity.center.SysTeam;
1213
import com.microsoft.hydralab.common.entity.center.SysUser;
14+
import com.microsoft.hydralab.common.entity.center.TeamAppRelation;
1315
import com.microsoft.hydralab.common.entity.center.UserTeamRelation;
1416
import com.microsoft.hydralab.common.util.Const;
1517
import org.apache.commons.lang3.StringUtils;
18+
import org.springframework.beans.factory.annotation.Autowired;
1619
import org.springframework.http.HttpStatus;
1720
import org.springframework.http.MediaType;
1821
import org.springframework.security.access.prepost.PreAuthorize;
@@ -29,7 +32,7 @@
2932

3033
@RestController
3134
@RequestMapping
32-
public class UserTeamController {
35+
public class TeamController {
3336
@Resource
3437
SysTeamService sysTeamService;
3538
@Resource
@@ -38,6 +41,8 @@ public class UserTeamController {
3841
UserTeamManagementService userTeamManagementService;
3942
@Resource
4043
SecurityUserService securityUserService;
44+
@Autowired
45+
private TeamAppManagementService teamAppManagementService;
4146

4247
@PreAuthorize("hasAnyAuthority('SUPER_ADMIN','ADMIN')")
4348
@PostMapping(value = {"/api/team/create"}, produces = MediaType.APPLICATION_JSON_VALUE)
@@ -311,4 +316,70 @@ public Result<SysTeam> queryDefaultTeam(@CurrentSecurityContext SysUser requesto
311316
return Result.ok(sysTeamService.queryTeamById(user.getDefaultTeamId()));
312317
}
313318

319+
/**
320+
* Authenticated USER: all
321+
*/
322+
@PostMapping(value = {"/api/teamApp/addRelation"}, produces = MediaType.APPLICATION_JSON_VALUE)
323+
public Result<TeamAppRelation> addTeamAppRelation(@CurrentSecurityContext SysUser requestor,
324+
@RequestParam("appClientId") String appClientId,
325+
@RequestParam("teamId") String teamId) {
326+
if (requestor == null) {
327+
return Result.error(HttpStatus.UNAUTHORIZED.value(), "Unauthorized");
328+
}
329+
/// todo: app client ID format check
330+
if (!sysUserService.checkUserAdmin(requestor) && !userTeamManagementService.checkRequestorTeamRelation(requestor, teamId)) {
331+
return Result.error(HttpStatus.UNAUTHORIZED.value(), "Unauthorized, user doesn't belong to this Team");
332+
}
333+
String existingTeamId = teamAppManagementService.queryTeamIdByClientId(appClientId);
334+
if (existingTeamId != null) {
335+
if (existingTeamId.equals(teamId)) {
336+
return Result.error(HttpStatus.FORBIDDEN.value(), "Client ID already linked in current team.");
337+
} else {
338+
return Result.error(HttpStatus.FORBIDDEN.value(), "Client ID already linked in another team.");
339+
}
340+
}
341+
342+
return Result.ok(teamAppManagementService.addTeamAppRelation(teamId, appClientId));
343+
}
344+
345+
/**
346+
* Authenticated USER: all
347+
*/
348+
@PostMapping(value = {"/api/teamApp/deleteRelation"}, produces = MediaType.APPLICATION_JSON_VALUE)
349+
public Result deleteTeamAppRelation(@CurrentSecurityContext SysUser requestor,
350+
@RequestParam("appClientId") String appClientId,
351+
@RequestParam("teamId") String teamId) {
352+
if (requestor == null) {
353+
return Result.error(HttpStatus.UNAUTHORIZED.value(), "Unauthorized");
354+
}
355+
/// todo: app client ID format check
356+
if (!sysUserService.checkUserAdmin(requestor) && !userTeamManagementService.checkRequestorTeamRelation(requestor, teamId)) {
357+
return Result.error(HttpStatus.UNAUTHORIZED.value(), "Unauthorized, user doesn't belong to this Team");
358+
}
359+
TeamAppRelation relation = teamAppManagementService.queryRelation(appClientId, teamId);
360+
if (relation == null) {
361+
return Result.error(HttpStatus.BAD_REQUEST.value(), "Relation doesn't exist.");
362+
}
363+
364+
teamAppManagementService.deleteTeamAppRelation(relation);
365+
return Result.ok("delete team-app relation successfully!");
366+
}
367+
368+
/**
369+
* Authenticated USER: all
370+
*/
371+
@PostMapping(value = {"/api/team/clientIds"}, produces = MediaType.APPLICATION_JSON_VALUE)
372+
public Result<List<String>> queryTeamClientIds(@CurrentSecurityContext SysUser requestor,
373+
@RequestParam("teamId") String teamId) {
374+
if (requestor == null) {
375+
return Result.error(HttpStatus.UNAUTHORIZED.value(), "Unauthorized");
376+
}
377+
/// todo: app client ID format check
378+
if (!sysUserService.checkUserAdmin(requestor) && !userTeamManagementService.checkRequestorTeamRelation(requestor, teamId)) {
379+
return Result.error(HttpStatus.UNAUTHORIZED.value(), "Unauthorized, user doesn't belong to this Team");
380+
}
381+
382+
List<String> clientIds = teamAppManagementService.queryClientIdsByTeam(teamId);
383+
return Result.ok(clientIds);
384+
}
314385
}

center/src/main/java/com/microsoft/hydralab/center/interceptor/BaseInterceptor.java

Lines changed: 22 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -40,12 +40,16 @@ public class BaseInterceptor extends HandlerInterceptorAdapter {
4040
AuthTokenService authTokenService;
4141
@Value("${app.storage.type}")
4242
private String storageType;
43+
@Value("${app.api-auth-mode}")
44+
private String apiAuthMode;
4345

4446
@Override
4547
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws IOException {
4648
String remoteUser = request.getRemoteUser();
4749
String requestURI = request.getRequestURI();
48-
String oauthToken = null;
50+
String sessionAuthToken = null;
51+
String authorizationToken;
52+
String aadIdToken;
4953
if (LogUtils.isLegalStr(requestURI, Const.RegexString.URL, true) && LogUtils.isLegalStr(remoteUser, Const.RegexString.MAIL_ADDRESS, true)) {
5054
LOGGER.info("New access from IP {}, host {}, user {}, for path {}", request.getRemoteAddr(), request.getRemoteHost(), remoteUser,
5155
requestURI);// CodeQL [java/log-injection] False Positive: Has verified the string by regular expression
@@ -64,41 +68,43 @@ public boolean preHandle(HttpServletRequest request, HttpServletResponse respons
6468
SecurityContext securityContext = (SecurityContext) request.getSession().getAttribute(HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY);
6569
if (securityContext != null) {
6670
SysUser userAuthentication = (SysUser) securityContext.getAuthentication();
67-
oauthToken = userAuthentication.getAccessToken();
71+
sessionAuthToken = userAuthentication.getAccessToken();
6872
}
6973

70-
String authToken = request.getHeader("Authorization");
71-
if (authToken != null) {
72-
authToken = authToken.replaceAll("Bearer ", "");
74+
authorizationToken = request.getHeader("Authorization");
75+
if (authorizationToken != null) {
76+
authorizationToken = authorizationToken.replaceAll("Bearer ", "");
7377
}
7478

7579
// For Azure AD authentication
76-
String accessToken = request.getHeader("X-MS-TOKEN-AAD-ID-TOKEN");
80+
aadIdToken = request.getHeader("X-MS-TOKEN-AAD-ID-TOKEN");
7781
LOGGER.info("UserId: " + request.getHeader("X-MS-CLIENT-PRINCIPAL-ID"));
7882
LOGGER.info("UserName: " + request.getHeader("X-MS-CLIENT-PRINCIPAL-NAME"));
7983

80-
//check is ignore
84+
// check is ignore
8185
if (!authUtil.isIgnore(requestURI)) {
82-
//invoked by API client
83-
if (!StringUtils.isEmpty(accessToken)) {
84-
if (authTokenService.checkAADToken(accessToken)) {
86+
// invoked by API client
87+
if (!StringUtils.isEmpty(aadIdToken)) {
88+
if (authTokenService.checkAADToken(aadIdToken)) {
8589
return true;
8690
} else {
8791
response.sendError(HttpStatus.UNAUTHORIZED.value(), "unauthorized, error authorization code");
8892
}
8993
}
9094

91-
//invoke by client
92-
if (!StringUtils.isEmpty(authToken)) {
93-
if (authTokenService.checkAuthToken(authToken)) {
95+
// invoked by agent client
96+
if (!StringUtils.isEmpty(authorizationToken)) {
97+
if ("SECRET".equals(apiAuthMode) && authTokenService.checkAuthToken(authorizationToken)) {
98+
return true;
99+
} else if ("TOKEN".equals(apiAuthMode) && authTokenService.setUserAuthByAppClientToken(authorizationToken)) {
94100
return true;
95101
} else {
96102
response.sendError(HttpStatus.UNAUTHORIZED.value(), "unauthorized, error authorization code");
97103
}
98-
99104
}
100-
//invoke by browser
101-
if (StringUtils.isEmpty(oauthToken) || !authUtil.verifyToken(oauthToken)) {
105+
106+
// invoke by browser
107+
if (StringUtils.isEmpty(sessionAuthToken) || !authUtil.verifyToken(sessionAuthToken)) {
102108
if (requestURI.contains(Const.FrontEndPath.PREFIX_PATH)) {
103109
String queryString = request.getQueryString();
104110
if (StringUtils.isNotEmpty(queryString)
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT License.
3+
4+
package com.microsoft.hydralab.center.repository;
5+
6+
import com.microsoft.hydralab.common.entity.center.TeamAppRelation;
7+
import com.microsoft.hydralab.common.entity.center.TeamAppRelationId;
8+
import org.springframework.data.jpa.repository.JpaRepository;
9+
import org.springframework.stereotype.Repository;
10+
11+
import java.util.Optional;
12+
13+
@Repository
14+
public interface TeamAppRelationRepository extends JpaRepository<TeamAppRelation, TeamAppRelationId> {
15+
Optional<TeamAppRelation> findByAppClientIdAndTeamId(String appClientId, String teamId);
16+
}

center/src/main/java/com/microsoft/hydralab/center/service/AuthTokenService.java

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import com.microsoft.hydralab.center.repository.AuthTokenRepository;
77
import com.microsoft.hydralab.center.util.AuthUtil;
88
import com.microsoft.hydralab.common.entity.center.AuthToken;
9+
import org.springframework.beans.factory.annotation.Autowired;
910
import org.springframework.security.core.Authentication;
1011
import org.springframework.security.core.context.SecurityContextHolder;
1112
import org.springframework.stereotype.Service;
@@ -24,6 +25,10 @@ public class AuthTokenService {
2425
AuthTokenRepository authTokenRepository;
2526
@Resource
2627
SecurityUserService securityUserService;
28+
@Autowired
29+
private TeamAppManagementService teamAppManagementService;
30+
@Autowired
31+
private UserTeamManagementService userTeamManagementService;
2732

2833
public AuthToken saveAuthToken(AuthToken authToken) {
2934
return authTokenRepository.save(authToken);
@@ -76,6 +81,23 @@ public boolean checkAADToken(String aadToken) {
7681
return true;
7782
}
7883

84+
public boolean setUserAuthByAppClientToken(String clientAadToken) {
85+
if (!authUtil.isValidToken(clientAadToken)) {
86+
return false;
87+
}
88+
String appClientId = authUtil.getAppClientId(clientAadToken);
89+
String teamId = teamAppManagementService.queryTeamIdByClientId(appClientId);
90+
Authentication authObj = userTeamManagementService.queryUsersByTeam(teamId).stream()
91+
.findFirst()
92+
.orElse(null);
93+
if (authObj == null) {
94+
return false;
95+
}
96+
97+
SecurityContextHolder.getContext().setAuthentication(authObj);
98+
return true;
99+
}
100+
79101
public void loadDefaultUser(HttpSession session) {
80102
securityUserService.addDefaultUserSession(session);
81103
}
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT License.
3+
4+
package com.microsoft.hydralab.center.service;
5+
6+
import com.microsoft.hydralab.center.repository.TeamAppRelationRepository;
7+
import com.microsoft.hydralab.common.entity.center.TeamAppRelation;
8+
import org.springframework.stereotype.Service;
9+
10+
import javax.annotation.PostConstruct;
11+
import javax.annotation.Resource;
12+
import java.util.ArrayList;
13+
import java.util.HashSet;
14+
import java.util.List;
15+
import java.util.Map;
16+
import java.util.Set;
17+
import java.util.concurrent.ConcurrentHashMap;
18+
19+
@Service
20+
public class TeamAppManagementService {
21+
// cache teamId -> App Client ID mapping <String, String>
22+
private final Map<String, Set<String>> teamAppListMap = new ConcurrentHashMap<>();
23+
// cache App Client ID -> teamId mapping <String, String>
24+
private final Map<String, String> appTeamListMap = new ConcurrentHashMap<>();
25+
@Resource
26+
private TeamAppRelationRepository teamAppRelationRepository;
27+
28+
@PostConstruct
29+
public void initList() {
30+
List<TeamAppRelation> relationList = teamAppRelationRepository.findAll();
31+
relationList.forEach(relation -> {
32+
this.insertToCache(relation.getTeamId(), relation.getAppClientId());
33+
});
34+
}
35+
36+
private void insertToCache(String teamId, String appClientId) {
37+
Set<String> clientIds = teamAppListMap.computeIfAbsent(teamId, k -> new HashSet<>());
38+
clientIds.add(appClientId);
39+
40+
appTeamListMap.put(appClientId, teamId);
41+
}
42+
43+
private void removeFromCache(TeamAppRelation relation) {
44+
Set<String> clientIdList = teamAppListMap.get(relation.getTeamId());
45+
if (clientIdList != null) {
46+
clientIdList.removeIf(clientId -> clientId.equals(relation.getAppClientId()));
47+
}
48+
appTeamListMap.remove(relation.getAppClientId());
49+
}
50+
51+
public TeamAppRelation addTeamAppRelation(String teamId, String appClientId) {
52+
this.insertToCache(teamId, appClientId);
53+
54+
TeamAppRelation teamAppRelation = new TeamAppRelation(teamId, appClientId);
55+
return teamAppRelationRepository.save(teamAppRelation);
56+
}
57+
58+
public void deleteTeamAppRelation(TeamAppRelation relation) {
59+
removeFromCache(relation);
60+
teamAppRelationRepository.delete(relation);
61+
}
62+
63+
public TeamAppRelation queryRelation(String appClientId, String teamId) {
64+
return teamAppRelationRepository.findByAppClientIdAndTeamId(appClientId, teamId).orElse(null);
65+
}
66+
67+
public List<String> queryClientIdsByTeam(String teamId) {
68+
Set<String> clientIds = teamAppListMap.get(teamId);
69+
if (clientIds == null) {
70+
return null;
71+
}
72+
73+
return new ArrayList<>(clientIds);
74+
}
75+
76+
public String queryTeamIdByClientId(String appClientId) {
77+
return appTeamListMap.get(appClientId);
78+
}
79+
}

center/src/main/java/com/microsoft/hydralab/center/util/AuthUtil.java

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,15 @@ private PublicKey getPublicKey(JWSObject jwsObject, JWKSet jwkSet) throws JOSEEx
134134
return publicKey;
135135
}
136136

137+
public String getAppClientId(String accessToken) {
138+
String clientId = "";
139+
JSONObject clientInfo = decodeAccessToken(accessToken);
140+
if (clientInfo != null) {
141+
clientId = clientInfo.getString("appid");
142+
}
143+
return clientId;
144+
}
145+
137146
/**
138147
* check the uri is need verify auth
139148
*

center/src/main/resources/application.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ app:
8484
deployment: ${OPENAI_DEPLOYMENT:}
8585
endpoint: ${OPENAI_ENDPOINT:}
8686
agent-auth-mode: ${AGENT_AUTH_MODE:SECRET} # options: TOKEN, SECRET
87+
api-auth-mode: ${API_AUTH_MODE:SECRET} # options: TOKEN, SECRET
8788
management:
8889
endpoints:
8990
web:
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT License.
3+
package com.microsoft.hydralab.common.entity.center;
4+
5+
import lombok.Data;
6+
import lombok.NoArgsConstructor;
7+
8+
import javax.persistence.Column;
9+
import javax.persistence.Entity;
10+
import javax.persistence.Id;
11+
import javax.persistence.IdClass;
12+
import javax.persistence.Table;
13+
import java.io.Serializable;
14+
15+
@Data
16+
@Entity
17+
@NoArgsConstructor
18+
@Table(name = "team_app_relation")
19+
@IdClass(TeamAppRelationId.class)
20+
public class TeamAppRelation implements Serializable {
21+
private static final long serialVersionUID = 1L;
22+
23+
@Id
24+
private String teamId;
25+
@Id
26+
@Column(unique = true)
27+
private String appClientId;
28+
29+
public TeamAppRelation(String teamId, String appClientId){
30+
this.teamId = teamId;
31+
this.appClientId = appClientId;
32+
}
33+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT License.
3+
package com.microsoft.hydralab.common.entity.center;
4+
5+
import lombok.Data;
6+
import lombok.NoArgsConstructor;
7+
8+
import java.io.Serializable;
9+
10+
11+
@Data
12+
@NoArgsConstructor
13+
public class TeamAppRelationId implements Serializable {
14+
private String appClientId;
15+
private String teamId;
16+
}

0 commit comments

Comments
 (0)