feat(knowledge): add application and principal space grants

This commit is contained in:
2026-07-16 10:26:28 +08:00
parent 735e9a78c1
commit 876a23a123
9 changed files with 607 additions and 0 deletions
+5
View File
@@ -31,6 +31,11 @@
<artifactId>ruoyi-common-web</artifactId>
</dependency>
<dependency>
<groupId>org.dromara</groupId>
<artifactId>ruoyi-common-redis</artifactId>
</dependency>
<dependency>
<groupId>org.dromara</groupId>
<artifactId>ruoyi-system</artifactId>
@@ -0,0 +1,37 @@
package org.dromara.aihr.knowledge.domain;
import java.time.LocalDateTime;
import java.util.List;
public final class AihrKnowledgeAppDto {
private AihrKnowledgeAppDto() {
}
public record AuthenticatedApp(
Long id,
String tenantId,
String appCode,
String appName,
String authType,
Integer rateLimitPerMinute,
LocalDateTime expiresTime
) {
}
public record AppResponse(
Long id,
String appCode,
String appName,
String authType,
String internalClientKey,
String status,
Integer rateLimitPerMinute,
LocalDateTime expiresTime,
List<String> spaceCodes
) {
}
public record TokenIssuedResponse(String appCode, String plainToken, LocalDateTime expiresTime) {
}
}
@@ -0,0 +1,18 @@
package org.dromara.aihr.knowledge.domain;
import java.util.Set;
public record AihrKnowledgePrincipal(
String tenantId,
Long userId,
String userType,
String extPartyId,
Set<String> roles,
Set<String> projectCodes,
String clientKey
) {
public AihrKnowledgePrincipal {
roles = roles == null ? Set.of() : Set.copyOf(roles);
projectCodes = projectCodes == null ? Set.of() : Set.copyOf(projectCodes);
}
}
@@ -0,0 +1,135 @@
package org.dromara.aihr.knowledge.service;
import lombok.RequiredArgsConstructor;
import org.dromara.aihr.knowledge.domain.AihrKnowledgeAppDto.AuthenticatedApp;
import org.dromara.aihr.knowledge.domain.AihrKnowledgePrincipal;
import org.dromara.common.core.constant.HttpStatus;
import org.dromara.common.core.exception.ServiceException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.Collection;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
@Service
@RequiredArgsConstructor
public class AihrKnowledgeAccessService {
private final JdbcTemplate jdbcTemplate;
public Set<Long> resolveInternalSpaceIds(AihrKnowledgePrincipal principal, AuthenticatedApp app,
List<String> requestedCodes, String permission) {
requireSameTenant(principal.tenantId(), app.tenantId());
Set<Long> appSpaces = appSpaces(app);
Set<Long> principalSpaces = principalSpaces(principal, permission);
Set<Long> requestedSpaces = requestedSpaceIds(principal.tenantId(), requestedCodes);
return intersect(appSpaces, principalSpaces, requestedSpaces);
}
public Set<Long> resolveExternalSpaceIds(AuthenticatedApp app, List<String> requestedCodes) {
Set<Long> appSpaces = appSpaces(app);
Set<Long> requestedSpaces = requestedSpaceIds(app.tenantId(), requestedCodes);
return narrow(appSpaces, requestedSpaces);
}
public static Set<Long> intersect(Set<Long> appSpaces, Set<Long> principalSpaces, Set<Long> requestedSpaces) {
Set<Long> effective = new LinkedHashSet<>(appSpaces);
effective.retainAll(principalSpaces);
return narrow(effective, requestedSpaces);
}
private static Set<Long> narrow(Set<Long> effective, Set<Long> requested) {
if (effective.isEmpty()) {
throw forbidden("当前应用和账号没有共同可访问的知识空间");
}
if (requested == null || requested.isEmpty()) {
return Set.copyOf(effective);
}
if (!effective.containsAll(requested)) {
throw forbidden("请求包含未授权的知识空间");
}
return Set.copyOf(requested);
}
private Set<Long> appSpaces(AuthenticatedApp app) {
return new LinkedHashSet<>(jdbcTemplate.queryForList("""
select s.knowledge_id
from aihr_knowledge_app_space s
join aihr_knowledge_info k on k.id = s.knowledge_id and k.tenant_id = s.tenant_id
where s.tenant_id = ? and s.app_id = ? and k.status = 'ACTIVE'
""", Long.class, app.tenantId(), app.id()));
}
private Set<Long> principalSpaces(AihrKnowledgePrincipal principal, String permission) {
List<Object> args = new ArrayList<>();
args.add(principal.tenantId());
args.add("MANAGE".equalsIgnoreCase(permission) ? "MANAGE" : "READ");
args.add(principal.userId() == null ? "" : String.valueOf(principal.userId()));
String roleClause = "";
if (!principal.roles().isEmpty()) {
roleClause = " or (g.principal_type = 'ROLE' and g.principal_value in (" + placeholders(principal.roles()) + "))";
args.addAll(principal.roles());
}
String permissionClause = "MANAGE".equalsIgnoreCase(permission)
? "g.permission = ?"
: "g.permission in (?, 'MANAGE')";
String sql = """
select distinct g.knowledge_id
from aihr_knowledge_space_grant g
join aihr_knowledge_info k on k.id = g.knowledge_id and k.tenant_id = g.tenant_id
where g.tenant_id = ? and %s and g.status = 'ACTIVE' and k.status = 'ACTIVE'
and ((g.principal_type = 'USER' and g.principal_value = ?)%s)
""".formatted(permissionClause, roleClause);
return new LinkedHashSet<>(jdbcTemplate.queryForList(sql, Long.class, args.toArray()));
}
private Set<Long> requestedSpaceIds(String tenantId, List<String> requestedCodes) {
Set<String> codes = cleanCodes(requestedCodes);
if (codes.isEmpty()) {
return Set.of();
}
List<Object> args = new ArrayList<>();
args.add(tenantId);
args.addAll(codes);
List<Long> ids = jdbcTemplate.queryForList("""
select id from aihr_knowledge_info
where tenant_id = ? and status = 'ACTIVE' and code in (%s)
""".formatted(placeholders(codes)), Long.class, args.toArray());
if (ids.size() != codes.size()) {
throw forbidden("请求包含不存在、停用或其他租户的知识空间");
}
return new LinkedHashSet<>(ids);
}
private static Set<String> cleanCodes(List<String> values) {
Set<String> result = new LinkedHashSet<>();
if (values != null) {
values.stream().filter(AihrKnowledgeAccessService::hasText).map(String::trim).forEach(result::add);
}
if (result.size() > 20) {
throw new ServiceException("单次最多选择 20 个知识空间", HttpStatus.BAD_REQUEST);
}
return result;
}
private static String placeholders(Collection<?> values) {
return String.join(",", java.util.Collections.nCopies(values.size(), "?"));
}
private static void requireSameTenant(String principalTenant, String appTenant) {
if (!hasText(principalTenant) || !principalTenant.equals(appTenant)) {
throw forbidden("调用应用不属于当前租户");
}
}
private static ServiceException forbidden(String message) {
return new ServiceException(message, HttpStatus.FORBIDDEN);
}
private static boolean hasText(String value) {
return value != null && !value.isBlank();
}
}
@@ -0,0 +1,205 @@
package org.dromara.aihr.knowledge.service;
import lombok.RequiredArgsConstructor;
import org.dromara.aihr.knowledge.domain.AihrKnowledgeAppDto.AuthenticatedApp;
import org.dromara.aihr.knowledge.domain.AihrKnowledgeAppDto.TokenIssuedResponse;
import org.dromara.common.core.constant.HttpStatus;
import org.dromara.common.core.exception.ServiceException;
import org.dromara.common.redis.utils.RedisUtils;
import org.redisson.api.RateType;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.time.LocalDateTime;
import java.util.Base64;
import java.util.List;
import java.util.Locale;
import java.util.regex.Pattern;
@Service
@RequiredArgsConstructor
public class AihrKnowledgeAppService {
private static final SecureRandom SECURE_RANDOM = new SecureRandom();
private static final Pattern APP_CODE = Pattern.compile("[a-z0-9_]{3,64}");
private static final int TOKEN_RANDOM_CHARS = 43;
private final JdbcTemplate jdbcTemplate;
public AuthenticatedApp requireSessionApp(String tenantId, String clientKey) {
if (!hasText(tenantId) || !hasText(clientKey)) {
throw forbidden("当前登录端未配置知识问答应用");
}
List<AppRow> rows = jdbcTemplate.query("""
select id, tenant_id, app_code, app_name, auth_type, token_hash, status,
rate_limit_per_minute, expires_time
from aihr_knowledge_app
where tenant_id = ? and auth_type = 'SESSION' and internal_client_key = ?
limit 1
""", (rs, rowNum) -> new AppRow(
rs.getLong("id"), rs.getString("tenant_id"), rs.getString("app_code"),
rs.getString("app_name"), rs.getString("auth_type"), rs.getString("token_hash"),
rs.getString("status"), rs.getInt("rate_limit_per_minute"),
rs.getTimestamp("expires_time") == null ? null : rs.getTimestamp("expires_time").toLocalDateTime()
), tenantId, clientKey.trim());
if (rows.isEmpty() || !active(rows.get(0))) {
throw forbidden("当前登录端未启用知识问答应用");
}
return rows.get(0).authenticated();
}
public AuthenticatedApp authenticateBearer(String authorization) {
String token = bearerToken(authorization);
String code;
try {
code = appCode(token);
} catch (IllegalArgumentException ex) {
throw unauthorized();
}
List<AppRow> rows = jdbcTemplate.query("""
select id, tenant_id, app_code, app_name, auth_type, token_hash, status,
rate_limit_per_minute, expires_time
from aihr_knowledge_app
where app_code = ? and auth_type = 'API_TOKEN'
limit 1
""", (rs, rowNum) -> new AppRow(
rs.getLong("id"), rs.getString("tenant_id"), rs.getString("app_code"),
rs.getString("app_name"), rs.getString("auth_type"), rs.getString("token_hash"),
rs.getString("status"), rs.getInt("rate_limit_per_minute"),
rs.getTimestamp("expires_time") == null ? null : rs.getTimestamp("expires_time").toLocalDateTime()
), code);
if (rows.isEmpty() || !active(rows.get(0)) || !hashMatches(token, rows.get(0).tokenHash())) {
throw unauthorized();
}
AppRow row = rows.get(0);
enforceRateLimit(row.authenticated());
jdbcTemplate.update("update aihr_knowledge_app set last_used_time = now() where id = ? and tenant_id = ?",
row.id(), row.tenantId());
return row.authenticated();
}
public TokenIssuedResponse rotateToken(String tenantId, Long appId, LocalDateTime expiresTime) {
List<String> codes = jdbcTemplate.queryForList("""
select app_code from aihr_knowledge_app
where tenant_id = ? and id = ? and auth_type = 'API_TOKEN'
""", String.class, tenantId, appId);
if (codes.isEmpty()) {
throw new ServiceException("外部调用应用不存在", HttpStatus.NOT_FOUND);
}
String token = generateToken(codes.get(0));
jdbcTemplate.update("""
update aihr_knowledge_app
set token_hash = ?, expires_time = ?, update_time = now()
where tenant_id = ? and id = ? and auth_type = 'API_TOKEN'
""", sha256(token), expiresTime, tenantId, appId);
return new TokenIssuedResponse(codes.get(0), token, expiresTime);
}
public void enforceRateLimit(AuthenticatedApp app) {
int rate = Math.max(1, app.rateLimitPerMinute() == null ? 60 : app.rateLimitPerMinute());
try {
if (RedisUtils.rateLimiter("knowledge:app:" + app.id(), RateType.OVERALL, rate, 60) < 0) {
throw new ServiceException("调用过于频繁,请稍后重试", 429);
}
} catch (ServiceException ex) {
throw ex;
} catch (RuntimeException ex) {
throw new ServiceException("知识问答限流服务暂不可用", 503);
}
}
public static String generateToken(String appCode) {
String code = normalizeCode(appCode);
byte[] random = new byte[32];
SECURE_RANDOM.nextBytes(random);
return "ak_" + code + "_" + Base64.getUrlEncoder().withoutPadding().encodeToString(random);
}
public static String appCode(String token) {
if (token == null || !token.startsWith("ak_")) {
throw new IllegalArgumentException("invalid app token");
}
int split = token.length() - TOKEN_RANDOM_CHARS - 1;
if (split <= 3 || split == token.length() - 1) {
throw new IllegalArgumentException("invalid app token");
}
if (token.charAt(split) != '_') {
throw new IllegalArgumentException("invalid app token");
}
return normalizeCode(token.substring(3, split));
}
public static String sha256(String value) {
try {
byte[] bytes = MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8));
return java.util.HexFormat.of().formatHex(bytes);
} catch (NoSuchAlgorithmException ex) {
throw new IllegalStateException("SHA-256 unavailable", ex);
}
}
public static boolean hashMatches(String token, String expectedHash) {
if (!hasText(token) || !hasText(expectedHash)) {
return false;
}
return MessageDigest.isEqual(
sha256(token).getBytes(StandardCharsets.US_ASCII),
expectedHash.getBytes(StandardCharsets.US_ASCII));
}
private static String bearerToken(String authorization) {
if (authorization == null || !authorization.regionMatches(true, 0, "Bearer ", 0, 7)) {
throw unauthorized();
}
String token = authorization.substring(7).trim();
if (token.isBlank()) {
throw unauthorized();
}
return token;
}
private static String normalizeCode(String value) {
String code = value == null ? "" : value.trim().toLowerCase(Locale.ROOT);
if (!APP_CODE.matcher(code).matches()) {
throw new IllegalArgumentException("appCode 仅支持 3-64 位小写字母、数字和下划线");
}
return code;
}
private static boolean active(AppRow row) {
return "ACTIVE".equals(row.status())
&& (row.expiresTime() == null || row.expiresTime().isAfter(LocalDateTime.now()));
}
private static ServiceException unauthorized() {
return new ServiceException("调用应用认证失败", HttpStatus.UNAUTHORIZED);
}
private static ServiceException forbidden(String message) {
return new ServiceException(message, HttpStatus.FORBIDDEN);
}
private static boolean hasText(String value) {
return value != null && !value.isBlank();
}
private record AppRow(
Long id,
String tenantId,
String appCode,
String appName,
String authType,
String tokenHash,
String status,
Integer rateLimit,
LocalDateTime expiresTime
) {
private AuthenticatedApp authenticated() {
return new AuthenticatedApp(id, tenantId, appCode, appName, authType, rateLimit, expiresTime);
}
}
}
@@ -0,0 +1,77 @@
package org.dromara.aihr.knowledge.service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.dromara.aihr.domain.AihrOrgSyncDto.OrgPersonRow;
import org.dromara.aihr.knowledge.domain.AihrKnowledgePrincipal;
import org.dromara.aihr.service.AihrOrgSyncService;
import org.dromara.common.core.domain.model.LoginUser;
import org.dromara.common.core.enums.UserType;
import org.dromara.common.core.exception.ServiceException;
import org.dromara.common.satoken.utils.LoginHelper;
import org.springframework.stereotype.Service;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
@Service
@RequiredArgsConstructor
@Slf4j
public class AihrKnowledgePrincipalResolver {
private final AihrOrgSyncService orgSyncService;
public AihrKnowledgePrincipal current() {
LoginUser loginUser = LoginHelper.getLoginUser();
if (loginUser == null || loginUser.getTenantId() == null || loginUser.getUserId() == null) {
throw new ServiceException("登录身份缺失,请重新登录", 401);
}
return from(loginUser);
}
AihrKnowledgePrincipal from(LoginUser loginUser) {
Set<String> roles = new LinkedHashSet<>();
Set<String> projectCodes = new LinkedHashSet<>();
String extPartyId = "";
if (UserType.APP_USER.getUserType().equals(loginUser.getUserType())) {
roles.add("employee");
extPartyId = trim(loginUser.getUsername());
List<OrgPersonRow> rows = orgRows(extPartyId);
if (rows.stream().anyMatch(AihrKnowledgePrincipalResolver::isSupervisor)) {
roles.add("supervisor");
}
rows.stream().map(OrgPersonRow::projectCode).filter(AihrKnowledgePrincipalResolver::hasText)
.forEach(projectCodes::add);
} else if (loginUser.getRolePermission() != null) {
roles.addAll(loginUser.getRolePermission());
}
return new AihrKnowledgePrincipal(
loginUser.getTenantId(), loginUser.getUserId(), loginUser.getUserType(), extPartyId,
roles, projectCodes, trim(loginUser.getClientKey()));
}
private List<OrgPersonRow> orgRows(String extPartyId) {
if (extPartyId.isBlank()) {
return List.of();
}
try {
return orgSyncService.snapshot(extPartyId, null, null, "active", 1, 500, 500).rows();
} catch (RuntimeException ex) {
log.warn("knowledge principal org lookup failed, keeping employee-only scope");
return List.of();
}
}
private static boolean isSupervisor(OrgPersonRow row) {
return "主管".equals(row.positionLevel()) || "项目经理".equals(row.positionLevel());
}
private static boolean hasText(String value) {
return value != null && !value.isBlank();
}
private static String trim(String value) {
return value == null ? "" : value.trim();
}
}
@@ -0,0 +1,35 @@
package org.dromara.aihr.knowledge;
import org.dromara.aihr.knowledge.service.AihrKnowledgeAccessService;
import org.dromara.common.core.exception.ServiceException;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
@Tag("dev")
class AihrKnowledgeAccessServiceTest {
@Test
void appAndPrincipalGrantsAreIntersected() {
assertEquals(Set.of(2L), AihrKnowledgeAccessService.intersect(
Set.of(1L, 2L), Set.of(2L, 3L), Set.of()));
}
@Test
void requestedSpacesCanOnlyNarrowEffectiveScope() {
assertEquals(Set.of(2L), AihrKnowledgeAccessService.intersect(
Set.of(1L, 2L), Set.of(2L, 3L), Set.of(2L)));
assertThrows(ServiceException.class, () -> AihrKnowledgeAccessService.intersect(
Set.of(1L, 2L), Set.of(2L, 3L), Set.of(2L, 3L)));
}
@Test
void emptyEffectiveScopeIsRejected() {
assertThrows(ServiceException.class, () -> AihrKnowledgeAccessService.intersect(
Set.of(1L), Set.of(2L), Set.of()));
}
}
@@ -0,0 +1,38 @@
package org.dromara.aihr.knowledge;
import org.dromara.aihr.knowledge.service.AihrKnowledgeAppService;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import java.nio.charset.StandardCharsets;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
@Tag("dev")
class AihrKnowledgeAppServiceTest {
@Test
void generatedTokenContainsAppCodeAndPersistsOnlySha256() {
String token = AihrKnowledgeAppService.generateToken("mt_card_miniapp");
String hash = AihrKnowledgeAppService.sha256(token);
assertTrue(token.startsWith("ak_mt_card_miniapp_"));
assertEquals("mt_card_miniapp", AihrKnowledgeAppService.appCode(token));
assertEquals(64, hash.length());
assertFalse(hash.contains("mt_card_miniapp"));
assertTrue(AihrKnowledgeAppService.hashMatches(token, hash));
assertFalse(AihrKnowledgeAppService.hashMatches(token + "x", hash));
}
@Test
void tokenUsesAtLeastThirtyTwoRandomBytes() {
String token = AihrKnowledgeAppService.generateToken("demo");
String randomPart = token.substring("ak_demo_".length());
assertTrue(java.util.Base64.getUrlDecoder().decode(randomPart).length >= 32);
assertFalse(java.util.Arrays.equals(
token.getBytes(StandardCharsets.UTF_8),
AihrKnowledgeAppService.generateToken("demo").getBytes(StandardCharsets.UTF_8)));
}
}
@@ -0,0 +1,57 @@
package org.dromara.aihr.knowledge.service;
import org.dromara.aihr.domain.AihrOrgSyncDto.OrgPersonRow;
import org.dromara.aihr.domain.AihrOrgSyncDto.OrgSnapshotResponse;
import org.dromara.aihr.service.AihrOrgSyncService;
import org.dromara.common.core.domain.model.LoginUser;
import org.dromara.common.core.enums.UserType;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import java.util.List;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
@Tag("dev")
class AihrKnowledgePrincipalResolverTest {
@Test
void appSupervisorRoleAndProjectsComeFromOrgSnapshot() {
AihrOrgSyncService orgService = mock(AihrOrgSyncService.class);
when(orgService.snapshot(eq("party-1"), any(), any(), eq("active"), eq(1), eq(500), eq(500)))
.thenReturn(new OrgSnapshotResponse(1, 1, 0, 1, 0, 1, 500, List.of(), List.of(), List.of(
new OrgPersonRow(1L, "P01", "", "", "party-1", "", "项目经理", "项目经理", "active", "2026-07-16")
)));
LoginUser login = login(UserType.APP_USER.getUserType(), "party-1", Set.of());
var principal = new AihrKnowledgePrincipalResolver(orgService).from(login);
assertEquals(Set.of("employee", "supervisor"), principal.roles());
assertEquals(Set.of("P01"), principal.projectCodes());
}
@Test
void sysUserRolesComeFromLoginUser() {
LoginUser login = login(UserType.SYS_USER.getUserType(), "admin", Set.of("hr_operator"));
var principal = new AihrKnowledgePrincipalResolver(mock(AihrOrgSyncService.class)).from(login);
assertTrue(principal.roles().contains("hr_operator"));
assertTrue(principal.projectCodes().isEmpty());
}
private static LoginUser login(String userType, String username, Set<String> roles) {
LoginUser login = new LoginUser();
login.setTenantId("000000");
login.setUserId(1L);
login.setUserType(userType);
login.setUsername(username);
login.setRolePermission(roles);
login.setClientKey("app");
return login;
}
}