英文:
Multi-Tenancy whit JWT autentication and validation of user for tenant
问题
我正在使用JWT身份验证运行项目,它已经运行正常,但现在我需要使用以下方法实现多租户功能:
要求:
- 用户可以访问一个或多个租户。
- 访问权限由用户和租户定义。
- 通过请求的
@RequestAttribute
获取子域名。 - 生成包含租户ID(子域名)的令牌。
- 在所有请求上验证租户。
已实现:
- 创建了JWT身份验证。
- 创建了
TenantInterceptor
。 - 在请求中使用
@RequestAttribute
获取子域名。 - 创建了
existsByUsernameAndSubdomain
验证。
我在实现这个新功能时遇到了困难,您能否指向一个实现示例或教程,可以帮助我?
非常感谢您的帮助!
如果需要,以下是我的类,或者您可以克隆它们在 GitHub 上查看。
以下是一些代码片段,这些片段是项目的一部分,用于演示上述功能。
如果您需要更多的帮助,请随时告诉我。
英文:
I have a project running with JWT authentication, it works, but now I need to implement Multi-Tenancy using the following approach:
Requirements:
- A user can have access to one or more tenants
- Access permissions are defined by user and tenant
- Getting subdomain through
@RequestAttribute
in requests - Generate the token containing the tenant ID (subdomain).
- Validate the tenant on all requests
Implemented:
- Created JWT Autentication.
- Created
TenantInterceptor
. - Getting subdomain using
@RequestAttribute
on requests. - Created
existsByUsernameAndSubdomain
validation.
I'm having trouble implementing this new feature, can you point me to an implementation example or tutorial that can help me?
I thank you for your help!
Below are my classes or if you prefer clone on GitHub!
My classes
Models:
<!-- begin snippet: js hide: true console: true babel: true -->
<!-- language: lang-js -->
/** ERole **/
public enum ERole {
ROLE_USER,
ROLE_MODERATOR,
ROLE_ADMIN
}
/** Role **/
@Entity
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
@Table(name = "roles")
public class Role {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Integer id;
@Enumerated(EnumType.STRING)
@Column(length = 20)
private ERole name;
}
/** Tenant **/
@Entity
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
@Table(name = "tenants",
uniqueConstraints = {
@UniqueConstraint(columnNames = "subdomain", name = "un_subdomain")
})
public class Tenant {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@NotBlank
@Size(max = 20)
private String subdomain;
@NotBlank
private String name;
}
/** User **/
@Entity
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
@Table(name = "users",
uniqueConstraints = {
@UniqueConstraint(columnNames = "username", name = "un_username")
})
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@NotBlank
@Size(max = 20)
private String username;
@NotBlank
@Size(max = 120)
@JsonIgnore
private String password;
// Remove
@ManyToMany(fetch = FetchType.LAZY)
@JoinTable(name = "users_roles",
joinColumns = {@JoinColumn(name = "user_id",
foreignKey = @ForeignKey(name = "fk_users_roles_users1"))},
inverseJoinColumns = {@JoinColumn(name = "role_id",
foreignKey = @ForeignKey(name = "fk_users_roles_roles1"))})
private Set<Role> roles = new HashSet<>();
// Include
@EqualsAndHashCode.Exclude
@OneToMany(mappedBy = "user",
cascade = CascadeType.ALL,
orphanRemoval = true,
fetch = FetchType.LAZY)
@JsonManagedReference
private List<UserTenant> tenants = new ArrayList<>();
public User(String username, String password) {
this.username = username;
this.password = password;
}
}
/** UserTenant **/
@Entity
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
@Table(name = "users_tenants",
uniqueConstraints = {
@UniqueConstraint(columnNames = "user_id", name = "un_user_id"),
@UniqueConstraint(columnNames = "tenant_id", name = "un_tenant_id")
})
public class UserTenant {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id",
nullable = false,
foreignKey = @ForeignKey(
name = "fk_users_tenants_user1"))
@JsonBackReference
private User user;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "tenant_id",
nullable = false,
foreignKey = @ForeignKey(
name = "fk_users_tenants_tenant1"))
@JsonBackReference
private Tenant tenant;
@ManyToMany(fetch = FetchType.LAZY)
@JoinTable(name = "users_tenants_roles",
joinColumns = {@JoinColumn(name = "user_tenant_id",
foreignKey = @ForeignKey(name = "fk_users_tenants_user_tenant1"))},
inverseJoinColumns = {@JoinColumn(name = "role_id",
foreignKey = @ForeignKey(name = "fk_users_tenants_roles1"))})
private Set<Role> roles = new HashSet<>();
}
<!-- end snippet -->
Payloads:
<!-- begin snippet: js hide: true console: true babel: true -->
<!-- language: lang-js -->
/** LoginRequest **/
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class LoginRequest {
@NotBlank
private String username;
@NotBlank
private String password;
}
/** SignupRequest **/
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class SignupRequest {
@NotBlank
@Size(max = 20)
private String username;
@NotBlank
@Size(max = 40)
private String password;
private Set<String> role;
}
/** JwtResponse **/
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class JwtResponse {
private Long id;
private String username;
private List<String> roles;
private String tokenType = "Bearer";
private String accessToken;
public JwtResponse(String accessToken, Long id, String username,
List<String> roles) {
this.id = id;
this.username = username;
this.roles = roles;
this.accessToken = accessToken;
}
}
/** MessageResponse **/
@Data
@Builder
@NoArgsConstructor
public class MessageResponse {
private String message;
public MessageResponse(String message) {
this.message = message;
}
}
<!-- end snippet -->
Repositories:
<!-- begin snippet: js hide: true console: true babel: true -->
<!-- language: lang-js -->
/** RoleRepository **/
@Repository
public interface RoleRepository extends JpaRepository<Role, Long> {
Optional<Role> findByName(ERole name);
}
/** UserRepository **/
@Repository
public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByUsername(String username);
Boolean existsByUsername(String username);
}
/** UserTenantRepository **/
@Repository
public interface UserTenantRepository extends JpaRepository<UserTenant, Long> {
@Query("SELECT ut FROM UserTenant ut WHERE ut.user.username = :username AND ut.tenant.subdomain = :subdomain ")
Optional<UserTenant> findByUserAndSubdomain(String username, String subdomain);
@Query("SELECT " +
"CASE WHEN COUNT(ut) > 0 THEN true ELSE false END " +
"FROM UserTenant ut " +
"WHERE ut.user.username = :username " +
"AND ut.tenant.subdomain = :subdomain ")
Boolean existsByUsernameAndSubdomain(String subdomain, String username);
}
<!-- end snippet -->
Services:
<!-- begin snippet: js hide: true console: true babel: true -->
<!-- language: lang-js -->
/** AuthService **/
@Service
@RequiredArgsConstructor
public class AuthService {
private final UserRepository userRepository;
private final AuthenticationManager authenticationManager;
private final JwtUtils jwtUtils;
private final PasswordEncoder encoder;
private final RoleRepository roleRepository;
public JwtResponse authenticateUser(String subdomain, LoginRequest loginRequest) {
System.out.println(subdomain);
Authentication authentication = authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(loginRequest.getUsername(), loginRequest.getPassword()));
System.out.println(authentication);
SecurityContextHolder.getContext().setAuthentication(authentication);
String jwt = jwtUtils.generateJwtToken(authentication);
UserDetailsImpl userDetails = (UserDetailsImpl) authentication.getPrincipal();
List<String> roles = userDetails.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.toList());
return new JwtResponse(jwt,
userDetails.getId(),
userDetails.getUsername(),
roles);
}
@Transactional
public MessageResponse registerUser(SignupRequest signUpRequest) {
// Create new user's account
User user = new User(
signUpRequest.getUsername(),
encoder.encode(signUpRequest.getPassword()));
Set<String> strRoles = signUpRequest.getRole();
Set<Role> roles = new HashSet<>();
if (strRoles == null) {
Role userRole = roleRepository.findByName(ERole.ROLE_USER)
.orElseThrow(() -> new RuntimeException("Error: Role is not found."));
roles.add(userRole);
} else {
strRoles.forEach(role -> {
switch (role) {
case "admin":
Role adminRole = roleRepository.findByName(ERole.ROLE_ADMIN)
.orElseThrow(() -> new RuntimeException("Error: Role is not found."));
roles.add(adminRole);
break;
case "mod":
Role modRole = roleRepository.findByName(ERole.ROLE_MODERATOR)
.orElseThrow(() -> new RuntimeException("Error: Role is not found."));
roles.add(modRole);
break;
default:
Role userRole = roleRepository.findByName(ERole.ROLE_USER)
.orElseThrow(() -> new RuntimeException("Error: Role is not found."));
roles.add(userRole);
}
});
}
user.setRoles(roles);
userRepository.save(user);
return new MessageResponse("User registered successfully!");
}
}
/** UserDetailsImpl **/
public class UserDetailsImpl implements UserDetails {
private static final long serialVersionUID = 1L;
private final Long id;
private final String username;
@JsonIgnore
private final String password;
private final Collection<? extends GrantedAuthority> authorities;
public UserDetailsImpl(Long id, String username, String password,
Collection<? extends GrantedAuthority> authorities) {
this.id = id;
this.username = username;
this.password = password;
this.authorities = authorities;
}
public static UserDetailsImpl build(User user) {
List<GrantedAuthority> authorities = user.getRoles().stream()
.map(role -> new SimpleGrantedAuthority(role.getName().name()))
.collect(Collectors.toList());
return new UserDetailsImpl(
user.getId(),
user.getUsername(),
user.getPassword(),
authorities);
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return authorities;
}
public Long getId() {
return id;
}
@Override
public String getPassword() {
return password;
}
@Override
public String getUsername() {
return username;
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
@Override
public boolean equals(Object o) {
if (this == o)
return true;
if (o == null || getClass() != o.getClass())
return false;
UserDetailsImpl user = (UserDetailsImpl) o;
return Objects.equals(id, user.id);
}
}
/** UserDetailsServiceImpl **/
@Service
@RequiredArgsConstructor
public class UserDetailsServiceImpl implements UserDetailsService {
private final UserRepository userRepository;
private final UserTenantRepository userTenantRepository;
@Override
@Transactional
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
User user = userRepository.findByUsername(username)
.orElseThrow(() -> new UsernameNotFoundException("User Not Found with username: " + username));
return UserDetailsImpl.build(user);
}
}
<!-- end snippet -->
Controller
<!-- begin snippet: js hide: true console: true babel: true -->
<!-- language: lang-js -->
/** AuthController **/
@RestController
@RequestMapping("/auth")
@RequiredArgsConstructor
public class AuthController {
private final AuthService authService;
private final UserRepository userRepository;
private final UserTenantRepository userTenantRepository;
@PostMapping("/signin")
public ResponseEntity<?> authenticateUser(
@RequestAttribute String subdomain,
@Valid @RequestBody LoginRequest loginRequest
) {
if (!userTenantRepository.existsByUsernameAndSubdomain(subdomain, loginRequest.getUsername())) {
return ResponseEntity
.badRequest()
.body(new MessageResponse("Unauthorized: This username and tenant is not authorized!"));
}
return ResponseEntity.ok(authService.authenticateUser(subdomain, loginRequest));
}
@PostMapping("/signup")
public ResponseEntity<?> registerUser(@Valid @RequestBody SignupRequest signUpRequest) {
if (userRepository.existsByUsername(signUpRequest.getUsername())) {
return ResponseEntity
.badRequest()
.body(new MessageResponse("Error: Username is already taken!"));
}
return ResponseEntity.ok(authService.registerUser(signUpRequest));
}
}
<!-- end snippet -->
JWT:
<!-- begin snippet: js hide: true console: true babel: true -->
<!-- language: lang-js -->
/** AuthEntryPointJwt **/
@Component
public class AuthEntryPointJwt implements AuthenticationEntryPoint {
private static final Logger logger = LoggerFactory.getLogger(AuthEntryPointJwt.class);
@Override
public void commence(HttpServletRequest request, HttpServletResponse response,
AuthenticationException authException) throws IOException {
logger.error("Unauthorized error: {}", authException.getMessage());
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Unauthorized: incorrect username or password");
}
}
/** AuthTokenFilter **/
public class AuthTokenFilter extends OncePerRequestFilter {
@Autowired
private JwtUtils jwtUtils;
@Autowired
private UserDetailsServiceImpl userDetailsService;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response
, FilterChain filterChain)
throws ServletException, IOException {
try {
String jwt = parseJwt(request);
if (jwt != null && jwtUtils.validateJwtToken(jwt)) {
String username = jwtUtils.getUserNameFromJwtToken(jwt);
String serverName = request.getServerName();
String subdomain = serverName.substring(0, serverName.indexOf("."));
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
System.out.println(userDetails);
UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(
userDetails, null, userDetails.getAuthorities());
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authentication);
}
} catch (Exception e) {
e.printStackTrace();
}
filterChain.doFilter(request, response);
}
private String parseJwt(HttpServletRequest request) {
String headerAuth = request.getHeader("Authorization");
if (StringUtils.hasText(headerAuth) && headerAuth.startsWith("Bearer ")) {
return headerAuth.substring(7);
}
return null;
}
}
/** JwtUtils **/
@Component
public class JwtUtils {
private static final Logger logger = LoggerFactory.getLogger(JwtUtils.class);
@Value("${example.app.jwtSecret}")
private String jwtSecret;
@Value("${example.app.jwtExpirationMs}")
private int jwtExpirationMs;
public String generateJwtToken(Authentication authentication) {
UserDetailsImpl userPrincipal = (UserDetailsImpl) authentication.getPrincipal();
return Jwts.builder()
.setSubject((userPrincipal.getUsername()))
.setIssuedAt(new Date())
.setExpiration(new Date((new Date()).getTime() + jwtExpirationMs))
.signWith(SignatureAlgorithm.HS512, jwtSecret)
.compact();
}
public String getUserNameFromJwtToken(String token) {
return Jwts.parser().setSigningKey(jwtSecret).parseClaimsJws(token).getBody().getSubject();
}
public boolean validateJwtToken(String authToken) {
try {
Jwts.parser().setSigningKey(jwtSecret).parseClaimsJws(authToken);
return true;
} catch (SignatureException e) {
logger.error("Invalid JWT signature: {}", e.getMessage());
} catch (MalformedJwtException e) {
logger.error("Invalid JWT token: {}", e.getMessage());
} catch (ExpiredJwtException e) {
logger.error("JWT token is expired: {}", e.getMessage());
} catch (UnsupportedJwtException e) {
logger.error("JWT token is unsupported: {}", e.getMessage());
} catch (IllegalArgumentException e) {
logger.error("JWT claims string is empty: {}", e.getMessage());
}
return false;
}
}
<!-- end snippet -->
Utils:
<!-- begin snippet: js hide: true console: true babel: true -->
<!-- language: lang-js -->
/** TenantInterceptor **/
public class TenantInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
String serverName = request.getServerName();
String tenantId = serverName.substring(0, serverName.indexOf("."));
request.setAttribute("subdomain", tenantId);
return true;
}
}
/** WebSecurityConfig **/
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class WebSecurityConfig implements WebMvcConfigurer {
final
UserDetailsServiceImpl userDetailsService;
private final AuthEntryPointJwt unauthorizedHandler;
public WebSecurityConfig(UserDetailsServiceImpl userDetailsService, AuthEntryPointJwt unauthorizedHandler) {
this.userDetailsService = userDetailsService;
this.unauthorizedHandler = unauthorizedHandler;
}
@Bean
public AuthTokenFilter authenticationJwtTokenFilter() {
return new AuthTokenFilter();
}
@Bean
public DaoAuthenticationProvider authenticationProvider() {
DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider();
authProvider.setUserDetailsService(userDetailsService);
authProvider.setPasswordEncoder(passwordEncoder());
return authProvider;
}
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration authConfig) throws Exception {
return authConfig.getAuthenticationManager();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.cors().and().csrf().disable()
.exceptionHandling().authenticationEntryPoint(unauthorizedHandler).and()
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS).and()
.authorizeRequests()
.antMatchers(
"/auth/**",
"/v3/api-docs/**",
"/swagger-ui/**",
"/swagger-ui.html",
"/configuration/**",
"/swagger-resources/**",
"/webjars/**",
"/api-docs/**").permitAll()
.antMatchers("/api/**").authenticated()
.anyRequest().authenticated();
http.authenticationProvider(authenticationProvider());
http.addFilterBefore(authenticationJwtTokenFilter(), UsernamePasswordAuthenticationFilter.class);
return http.build();
}
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new TenantInterceptor());
}
}
<!-- end snippet -->
答案1
得分: 0
我通过修改UserDetailsServiceImpl
中的loadUserByUsername
方法来解决了这个问题。
在**GitHub**上查看实现细节!
@Service
@RequiredArgsConstructor
public class UserDetailsServiceImpl implements UserDetailsService {
private final UserTenantRepository userTenantRepository;
@Override
@Transactional
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
// 从请求属性中获取子域名
HttpServletRequest request =
((ServletRequestAttributes) Objects.requireNonNull(RequestContextHolder.getRequestAttributes()))
.getRequest();
String serverName = request.getServerName();
String subdomain = serverName.substring(0, serverName.indexOf("."));
UserTenant userTenant = userTenantRepository.findByUserAndSubdomain(username, subdomain)
.orElseThrow(() -> new UsernameNotFoundException(
"UserTenant Not Found with username: " + username + " and " + subdomain));
// 从UserTenant获取权限
List<GrantedAuthority> authorities = userTenant.getRoles().stream()
.map(role -> new SimpleGrantedAuthority(role.getName().name()))
.collect(Collectors.toList());
return new UserDetailsImpl(
userTenant.getUser().getId(),
userTenant.getUser().getUsername(),
userTenant.getUser().getPassword(),
authorities
);
}
}
数据库插入
INSERT INTO roles(id, name)
VALUES (1, 'ROLE_USER'),
(2, 'ROLE_MODERATOR'),
(3, 'ROLE_ADMIN');
INSERT INTO tenants (id, name, subdomain)
VALUES (1, 'Tenant 1', 'tenant1'),
(2, 'Tenant 2', 'tenant2');
# 用户,密码
# user1, user1
# user2, user2
INSERT INTO users (id, username, password)
VALUES (1, 'user1', '$2a$10$wFMJLxdXKGRa8lJO6k2DAOnW9HstAPoHecXUNkDyYNeaNnZJAz.hy'),
(2, 'user2', '$2a$10$Z9/wLkmf5IwfjJqIQU6X.OBFg3TCBUyk3bdfgkGjU0.HI5kVibZxG');
INSERT INTO users_tenants (id, tenant_id, user_id)
VALUES (1, 1, 1),
(2, 2, 2);
INSERT INTO users_tenants_roles (user_tenant_id, role_id)
VALUES (1, 2),
(1, 3),
(2, 1);
INSERT INTO items (id, tenant_id, name)
VALUES (1, 1, 'Tenant 1中的产品1'),
(2, 1, 'Tenant 1中的产品2'),
(3, 2, 'Tenant 2中的产品1'),
(4, 2, 'Tenant 2中的产品2');
Postman中的验证
尝试在未登录tenant2并没有访问权限的情况下获取tenant2的项目列表:
尝试在未登录tenant1且没有访问权限的情况下获取tenant1的项目列表:
英文:
I was able to solve the problem by modifying the loadUserByUsername
method in the UserDetailsServiceImpl
.
See the implementation details on GitHub!
@Service
@RequiredArgsConstructor
public class UserDetailsServiceImpl implements UserDetailsService {
private final UserTenantRepository userTenantRepository;
@Override
@Transactional
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
// Getting subdomain from request attributes
HttpServletRequest request =
((ServletRequestAttributes) Objects.requireNonNull(RequestContextHolder.getRequestAttributes()))
.getRequest();
String serverName = request.getServerName();
String subdomain = serverName.substring(0, serverName.indexOf("."));
UserTenant userTenant = userTenantRepository.findByUserAndSubdomain(username, subdomain)
.orElseThrow(() -> new UsernameNotFoundException(
"UserTenant Not Found with username: " + username + " and " + subdomain));
// Getting Rules from the UserTenant
List<GrantedAuthority> authorities = userTenant.getRoles().stream()
.map(role -> new SimpleGrantedAuthority(role.getName().name()))
.collect(Collectors.toList());
return new UserDetailsImpl(
userTenant.getUser().getId(),
userTenant.getUser().getUsername(),
userTenant.getUser().getPassword(),
authorities
);
}
}
Inserts in Database
INSERT INTO roles(id, name)
VALUES (1 ,'ROLE_USER'),
(2, 'ROLE_MODERATOR'),
(3, 'ROLE_ADMIN');
INSERT INTO tenants (id, name, subdomain)
VALUES (1, 'Tenant 1', 'tenant1'),
(2, 'Tenant 2', 'tenant2');
# user, password
# user1, user1
# user2, user2
INSERT INTO users (id, username, password)
VALUES (1, 'user1', '$2a$10$wFMJLxdXKGRa8lJO6k2DAOnW9HstAPoHecXUNkDyYNeaNnZJAz.hy'),
(2, 'user2', '$2a$10$Z9/wLkmf5IwfjJqIQU6X.OBFg3TCBUyk3bdfgkGjU0.HI5kVibZxG');
INSERT INTO users_tenants (id, tenant_id, user_id)
VALUES (1, 1, 1),
(2, 2, 2);
INSERT INTO users_tenants_roles (user_tenant_id, role_id)
VALUES (1, 2),
(1, 3),
(2, 1);
INSERT INTO items (id, tenant_id, name)
VALUES (1, 1, 'Product 1 in Tenant 1'),
(2, 1, 'Product 2 in Tenant 1'),
(3, 2, 'Product 1 in Tenant 2'),
(4, 2, 'Product 2 in Tenant 2');
Validations in Postman
Created token variable in Postman:
Set token value in Postman variable:
Added Authorization variable in requests headers:
Validating if the domain exists in sign in:
Validating user access permission on tenant:
Trying to get tenant2 list of items without being logged in tenant2 and having access permissions:
Logging in with user2 in tenant2:
Trying to get tenant1 list of items without being logged in tenant1 and having access permissions:
通过集体智慧和协作来改善编程学习和解决问题的方式。致力于成为全球开发者共同参与的知识库,让每个人都能够通过互相帮助和分享经验来进步。
评论