feat(personal): authorize enterprise knowledge from org ACL

This commit is contained in:
2026-07-12 15:45:46 +08:00
parent 3a3562abb4
commit 2161353247
8 changed files with 471 additions and 6 deletions
@@ -5,10 +5,7 @@ import org.dromara.aihr.personal.support.PersonalOwner;
import java.util.List;
import java.util.Optional;
/**
* Server-side enterprise knowledge grant. No default bean is provided: enterprise scope stays disabled until
* an authenticated organization/role policy is wired.
*/
/** Server-side enterprise knowledge grant resolved from trusted organization and ACL data. */
@FunctionalInterface
public interface EnterpriseKnowledgeAccessPolicy {
@@ -0,0 +1,152 @@
package org.dromara.aihr.personal.service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.dromara.aihr.personal.support.PersonalOwner;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.Locale;
import java.util.Optional;
/**
* Resolves enterprise knowledge grants from the authenticated user, the local organization snapshot and explicit
* enterprise knowledge ACL rows. Missing or broken identity/ACL data always denies access.
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class OrgSnapshotEnterpriseKnowledgeAccessPolicy implements EnterpriseKnowledgeAccessPolicy {
private static final int MAX_FRAGMENT_GRANTS = 200;
private final JdbcTemplate jdbcTemplate;
@Override
public Optional<EnterpriseKnowledgeGrant> authorize(PersonalOwner owner) {
if (owner == null || owner.userId() <= 0 || isBlank(owner.tenantId())) {
return Optional.empty();
}
try {
Optional<String> phone = userPhone(owner);
if (phone.isEmpty()) {
return denied(owner, "user_phone_missing");
}
Optional<OrganizationIdentity> organization = organization(owner.tenantId(), phone.orElseThrow());
if (organization.isEmpty()) {
return denied(owner, "active_org_missing");
}
OrganizationIdentity identity = organization.orElseThrow();
List<Long> fragmentIds = authorizedFragmentIds(owner.tenantId(), identity);
if (fragmentIds.isEmpty()) {
return denied(owner, "acl_fragments_missing");
}
return Optional.of(new EnterpriseKnowledgeGrant(owner.tenantId(), owner.userId(),
identity.positionName(), fragmentIds));
} catch (RuntimeException ex) {
log.warn("enterprise_acl_denied tenant={} userId={} reason=db_error errorType={}",
safeTenant(owner), owner.userId(), ex.getClass().getSimpleName());
return Optional.empty();
}
}
private Optional<String> userPhone(PersonalOwner owner) {
List<String> phones = jdbcTemplate.query("""
SELECT phonenumber
FROM sys_user
WHERE BINARY tenant_id = BINARY ?
AND user_id = ?
AND status = '0'
AND del_flag = '0'
AND phonenumber IS NOT NULL
AND phonenumber <> ''
ORDER BY user_id
LIMIT 1
""", (rs, rowNum) -> rs.getString("phonenumber"), owner.tenantId(), owner.userId());
return phones.stream().map(String::trim).filter(value -> !value.isEmpty()).findFirst();
}
private Optional<OrganizationIdentity> organization(String tenantId, String phone) {
List<OrganizationIdentity> rows = jdbcTemplate.query("""
SELECT project_code, position_name, position_level
FROM aihr_org_snapshot
WHERE BINARY tenant_id = BINARY ?
AND person_phone = ?
AND employment_status = 'active'
AND project_code IS NOT NULL
AND project_code <> ''
AND position_name IS NOT NULL
AND position_name <> ''
ORDER BY snapshot_date DESC, id ASC
LIMIT 1
""", (rs, rowNum) -> new OrganizationIdentity(
trimmed(rs.getString("project_code")),
trimmed(rs.getString("position_name")),
trimmed(rs.getString("position_level"))), tenantId, phone);
return rows.stream().filter(OrganizationIdentity::valid).findFirst();
}
private List<Long> authorizedFragmentIds(String tenantId, OrganizationIdentity identity) {
String canonicalPosition = canonicalPosition(identity.positionName());
return jdbcTemplate.query("""
SELECT DISTINCT f.id AS fragment_id
FROM aihr_knowledge_acl a
JOIN aihr_knowledge_info i
ON i.id = a.knowledge_id
AND BINARY i.tenant_id = BINARY a.tenant_id
JOIN aihr_knowledge_fragment f
ON f.knowledge_id = a.knowledge_id
AND BINARY f.tenant_id = BINARY a.tenant_id
WHERE BINARY a.tenant_id = BINARY ?
AND a.enabled = 1
AND (
a.access_scope = 'TENANT'
OR (a.access_scope = 'PROJECT' AND a.project_code = ?)
OR (a.access_scope = 'POSITION'
AND (a.project_code IS NULL OR a.project_code = '' OR a.project_code = ?)
AND a.position_name = ?
AND (a.position_level IS NULL OR a.position_level = '' OR a.position_level = ?))
)
ORDER BY f.id ASC
LIMIT 200
""", (rs, rowNum) -> rs.getLong("fragment_id"), tenantId, identity.projectCode(),
identity.projectCode(), canonicalPosition, identity.positionLevel()).stream()
.filter(id -> id != null && id > 0)
.distinct()
.sorted()
.limit(MAX_FRAGMENT_GRANTS)
.toList();
}
private Optional<EnterpriseKnowledgeGrant> denied(PersonalOwner owner, String reason) {
log.warn("enterprise_acl_denied tenant={} userId={} reason={}", safeTenant(owner), owner.userId(), reason);
return Optional.empty();
}
static String canonicalPosition(String position) {
String normalized = trimmed(position).replaceAll("\\s+", "").toLowerCase(Locale.ROOT);
return switch (normalized) {
case "生活顾问", "物业管家", "客服管家" -> "生活顾问";
default -> trimmed(position);
};
}
private static String safeTenant(PersonalOwner owner) {
return owner == null || owner.tenantId() == null ? "unknown" : owner.tenantId();
}
private static String trimmed(String value) {
return value == null ? "" : value.trim();
}
private static boolean isBlank(String value) {
return value == null || value.isBlank();
}
private record OrganizationIdentity(String projectCode, String positionName, String positionLevel) {
private boolean valid() {
return !projectCode.isBlank() && !positionName.isBlank();
}
}
}
@@ -0,0 +1,246 @@
package org.dromara.aihr.personal;
import org.dromara.aihr.personal.service.EnterpriseKnowledgeAccessPolicy;
import org.dromara.aihr.personal.service.OrgSnapshotEnterpriseKnowledgeAccessPolicy;
import org.dromara.aihr.personal.support.PersonalOwner;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.springframework.dao.DataAccessResourceFailureException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;
import org.springframework.context.annotation.ClassPathScanningCandidateComponentProvider;
import org.springframework.core.type.filter.AssignableTypeFilter;
import java.sql.ResultSet;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.stream.LongStream;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
@Tag("dev")
class OrgSnapshotEnterpriseKnowledgeAccessPolicyTest {
@Test
void activeOrganizationMemberReceivesOnlyAclAuthorizedFragments() {
RecordingJdbcTemplate jdbc = new RecordingJdbcTemplate();
jdbc.phone = "13900000103";
jdbc.organization = Map.of(
"project_code", "PRJ-FCW",
"position_name", "物业管家",
"position_level", "一线"
);
jdbc.fragmentIds = List.of(100101L, 100201L, 100301L);
var policy = new OrgSnapshotEnterpriseKnowledgeAccessPolicy(jdbc);
var grant = policy.authorize(new PersonalOwner("000000", 103L, null)).orElseThrow();
assertEquals("000000", grant.tenantId());
assertEquals(103L, grant.userId());
assertEquals("物业管家", grant.position());
assertEquals(List.of(100101L, 100201L, 100301L), grant.allowedFragmentIds());
assertTrue(jdbc.sql.stream().anyMatch(value -> value.contains("FROM aihr_knowledge_acl")));
}
@Test
void policyIsTheSingleSpringProductionImplementation() {
var scanner = new ClassPathScanningCandidateComponentProvider(false);
scanner.addIncludeFilter(new AssignableTypeFilter(EnterpriseKnowledgeAccessPolicy.class));
var implementations = scanner.findCandidateComponents("org.dromara.aihr")
.stream().map(definition -> definition.getBeanClassName()).toList();
assertEquals(List.of(OrgSnapshotEnterpriseKnowledgeAccessPolicy.class.getName()), implementations);
}
@Test
void missingPhoneOrganizationOrAclFailsClosed() {
PersonalOwner owner = new PersonalOwner("000000", 103L, null);
assertTrue(new OrgSnapshotEnterpriseKnowledgeAccessPolicy(new RecordingJdbcTemplate())
.authorize(owner).isEmpty());
RecordingJdbcTemplate withoutOrg = new RecordingJdbcTemplate();
withoutOrg.phone = "13900000103";
assertTrue(new OrgSnapshotEnterpriseKnowledgeAccessPolicy(withoutOrg).authorize(owner).isEmpty());
RecordingJdbcTemplate withoutAcl = fixture();
assertTrue(new OrgSnapshotEnterpriseKnowledgeAccessPolicy(withoutAcl).authorize(owner).isEmpty());
}
@Test
void organizationLookupRequiresSameTenantAndActiveEmployment() {
RecordingJdbcTemplate jdbc = fixture();
jdbc.expectedTenant = "000000";
jdbc.fragmentIds = List.of(100101L);
assertTrue(new OrgSnapshotEnterpriseKnowledgeAccessPolicy(jdbc)
.authorize(new PersonalOwner("999999", 103L, null)).isEmpty());
assertFalse(jdbc.sql.stream().anyMatch(value -> value.contains("FROM aihr_org_snapshot")));
jdbc = fixture();
jdbc.organization = null;
assertTrue(new OrgSnapshotEnterpriseKnowledgeAccessPolicy(jdbc)
.authorize(new PersonalOwner("000000", 103L, null)).isEmpty());
assertTrue(jdbc.sql.stream().anyMatch(value -> value.contains("employment_status = 'active'")));
}
@Test
void aclQueryEnforcesTenantProjectPositionAndTenantScopes() {
RecordingJdbcTemplate jdbc = fixture();
jdbc.fragmentIds = List.of(100101L);
new OrgSnapshotEnterpriseKnowledgeAccessPolicy(jdbc)
.authorize(new PersonalOwner("000000", 103L, null)).orElseThrow();
String aclSql = jdbc.sql.stream().filter(value -> value.contains("FROM aihr_knowledge_acl"))
.findFirst().orElseThrow();
assertTrue(aclSql.contains("a.enabled = 1"));
assertTrue(aclSql.contains("a.access_scope = 'TENANT'"));
assertTrue(aclSql.contains("a.access_scope = 'PROJECT' AND a.project_code = ?"));
assertTrue(aclSql.contains("a.access_scope = 'POSITION'"));
assertTrue(aclSql.contains("JOIN aihr_knowledge_fragment"));
assertEquals(List.of("000000", "PRJ-FCW", "PRJ-FCW", "生活顾问", "一线"),
jdbc.args.get(jdbc.args.size() - 1));
}
@Test
void positionAliasesAreResolvedOnlyOnServer() {
for (String position : List.of("生活顾问", "物业管家", "客服管家")) {
RecordingJdbcTemplate jdbc = fixture();
jdbc.organization = Map.of(
"project_code", "PRJ-FCW",
"position_name", position,
"position_level", "一线"
);
jdbc.fragmentIds = List.of(100101L);
new OrgSnapshotEnterpriseKnowledgeAccessPolicy(jdbc)
.authorize(new PersonalOwner("000000", 103L, null)).orElseThrow();
assertEquals("生活顾问", jdbc.args.get(jdbc.args.size() - 1).get(3));
}
}
@Test
void organizationValuesStayBoundParametersInsteadOfSqlText() {
RecordingJdbcTemplate jdbc = fixture();
String maliciousProject = "PRJ' OR 1=1 --";
String maliciousPosition = "生活顾问' OR 1=1 --";
jdbc.organization = Map.of(
"project_code", maliciousProject,
"position_name", maliciousPosition,
"position_level", "一线' OR 1=1 --"
);
jdbc.fragmentIds = List.of(100101L);
new OrgSnapshotEnterpriseKnowledgeAccessPolicy(jdbc)
.authorize(new PersonalOwner("000000", 103L, null)).orElseThrow();
String aclSql = jdbc.sql.get(jdbc.sql.size() - 1);
assertFalse(aclSql.contains(maliciousProject));
assertFalse(aclSql.contains(maliciousPosition));
assertTrue(jdbc.args.get(jdbc.args.size() - 1).contains(maliciousProject));
assertTrue(jdbc.args.get(jdbc.args.size() - 1).contains(maliciousPosition));
}
@Test
void fragmentGrantIsStableDistinctAndCappedAtTwoHundred() {
RecordingJdbcTemplate jdbc = fixture();
List<Long> ids = new ArrayList<>(LongStream.rangeClosed(1, 250).boxed().toList());
ids.add(1L);
jdbc.fragmentIds = ids;
var grant = new OrgSnapshotEnterpriseKnowledgeAccessPolicy(jdbc)
.authorize(new PersonalOwner("000000", 103L, null)).orElseThrow();
assertEquals(200, grant.allowedFragmentIds().size());
assertEquals(1L, grant.allowedFragmentIds().get(0));
assertEquals(200L, grant.allowedFragmentIds().get(199));
}
@Test
void databaseFailureFailsClosed() {
RecordingJdbcTemplate jdbc = fixture();
jdbc.fail = true;
assertTrue(new OrgSnapshotEnterpriseKnowledgeAccessPolicy(jdbc)
.authorize(new PersonalOwner("000000", 103L, null)).isEmpty());
}
private static RecordingJdbcTemplate fixture() {
RecordingJdbcTemplate jdbc = new RecordingJdbcTemplate();
jdbc.phone = "13900000103";
jdbc.organization = Map.of(
"project_code", "PRJ-FCW",
"position_name", "物业管家",
"position_level", "一线"
);
return jdbc;
}
private static final class RecordingJdbcTemplate extends JdbcTemplate {
private final List<String> sql = new ArrayList<>();
private final List<List<Object>> args = new ArrayList<>();
private String phone;
private String expectedTenant;
private Map<String, String> organization;
private List<Long> fragmentIds = List.of();
private boolean fail;
@Override
public <T> List<T> query(String sql, RowMapper<T> rowMapper, Object... args) {
this.sql.add(sql);
this.args.add(List.of(args));
if (fail) {
throw new DataAccessResourceFailureException("database unavailable");
}
if (sql.contains("FROM sys_user")) {
if (expectedTenant != null && !expectedTenant.equals(args[0])) {
return List.of();
}
return phone == null ? List.of() : mapRows(rowMapper, List.of(Map.of("phonenumber", phone)));
}
if (sql.contains("FROM aihr_org_snapshot")) {
return organization == null ? List.of() : mapRows(rowMapper, List.of(organization));
}
if (sql.contains("FROM aihr_knowledge_acl")) {
return fragmentIds.stream().map(id -> mapRow(rowMapper, Map.of("fragment_id", id))).toList();
}
return List.of();
}
private static <T> List<T> mapRows(RowMapper<T> mapper, List<Map<String, ?>> rows) {
List<T> result = new ArrayList<>();
for (int index = 0; index < rows.size(); index++) {
result.add(mapRow(mapper, rows.get(index), index));
}
return result;
}
private static <T> T mapRow(RowMapper<T> mapper, Map<String, ?> row) {
return mapRow(mapper, row, 0);
}
private static <T> T mapRow(RowMapper<T> mapper, Map<String, ?> row, int rowNum) {
try {
ResultSet resultSet = mock(ResultSet.class);
for (Map.Entry<String, ?> entry : row.entrySet()) {
Object value = entry.getValue();
when(resultSet.getString(entry.getKey())).thenReturn(value == null ? null : value.toString());
if (value instanceof Number number) {
when(resultSet.getLong(entry.getKey())).thenReturn(number.longValue());
}
}
return mapper.mapRow(resultSet, rowNum);
} catch (Exception ex) {
throw new IllegalStateException(ex);
}
}
}
}
@@ -16,6 +16,32 @@ class PersonalSchemaContractTest {
private static final String SCHEMA_FILE = "aihr_personal_knowledge_mysql8.sql";
@Test
void enterpriseKnowledgeAclIsIndependentAndDefaultDeny() throws IOException {
Path projectRoot = locateProjectRoot();
String sql = Files.readString(projectRoot.resolve("backend/script/sql/aihr_knowledge_mysql8.sql"))
.toLowerCase(Locale.ROOT);
String acl = tableDefinition(sql, "aihr_knowledge_acl");
assertTrue(acl.contains("`tenant_id` varchar(20) not null"));
assertTrue(acl.contains("`knowledge_id` bigint not null"));
assertTrue(acl.contains("`access_scope` varchar(20) not null"));
assertTrue(acl.contains("`project_code` varchar(50) default null"));
assertTrue(acl.contains("`position_name` varchar(100) default null"));
assertTrue(acl.contains("`position_level` varchar(30) default null"));
assertTrue(acl.contains("`classification` varchar(20) not null default 'internal'"));
assertTrue(acl.contains("`enabled` tinyint(1) not null default 1"));
assertTrue(acl.contains("key `idx_aihr_knowledge_acl_lookup` (`tenant_id`, `enabled`, `access_scope`)"));
assertTrue(acl.contains("unique key `uk_aihr_knowledge_acl_rule`"));
assertTrue(sql.contains("(11001, '000000', 1001, 'position'"));
assertTrue(sql.contains("(11002, '000000', 1002, 'position'"));
assertTrue(sql.contains("(11003, '000000', 1003, 'position'"));
assertTrue(sql.contains("'生活顾问', '一线', 'internal', 1"));
assertFalse(sql.contains("'tenant', null, null, null, 'internal', 1"),
"Seed SOP knowledge must not be tenant-wide");
}
@Test
void personalKnowledgeSchemaIsOwnerScopedAndIndependent() throws IOException {
Path projectRoot = locateProjectRoot();
@@ -68,6 +68,26 @@ CREATE TABLE IF NOT EXISTS `aihr_knowledge_fragment` (
FULLTEXT KEY `ft_aihr_knowledge_fragment_content` (`content`) WITH PARSER ngram
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='AI HR 知识片段';
CREATE TABLE IF NOT EXISTS `aihr_knowledge_acl` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
`tenant_id` varchar(20) NOT NULL COMMENT '租户编号',
`knowledge_id` bigint NOT NULL COMMENT '企业知识库ID',
`access_scope` varchar(20) NOT NULL COMMENT '授权范围:TENANT/PROJECT/POSITION',
`project_code` varchar(50) DEFAULT NULL COMMENT '项目编码,PROJECT必填,POSITION可选',
`position_name` varchar(100) DEFAULT NULL COMMENT '规范岗位名称,POSITION必填',
`position_level` varchar(30) DEFAULT NULL COMMENT '岗位层级,POSITION可选',
`classification` varchar(20) NOT NULL DEFAULT 'INTERNAL' COMMENT '知识密级:INTERNAL/RESTRICTED',
`enabled` tinyint(1) NOT NULL DEFAULT 1 COMMENT '是否启用',
`rule_key` varchar(220) GENERATED ALWAYS AS
(concat(`access_scope`, '|', ifnull(`project_code`, ''), '|', ifnull(`position_name`, ''), '|', ifnull(`position_level`, ''))) STORED,
`create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_aihr_knowledge_acl_rule` (`tenant_id`, `knowledge_id`, `rule_key`),
KEY `idx_aihr_knowledge_acl_lookup` (`tenant_id`, `enabled`, `access_scope`),
KEY `idx_aihr_knowledge_acl_knowledge` (`tenant_id`, `knowledge_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='企业知识访问控制,默认无ACL不可见';
CREATE TABLE IF NOT EXISTS `aihr_knowledge_import_task` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
`tenant_id` varchar(20) DEFAULT '000000' COMMENT '租户编号',
@@ -125,6 +145,17 @@ ON DUPLICATE KEY UPDATE
`content` = VALUES(`content`),
`update_time` = NOW();
INSERT INTO `aihr_knowledge_acl`
(`id`, `tenant_id`, `knowledge_id`, `access_scope`, `project_code`, `position_name`, `position_level`, `classification`, `enabled`, `create_time`, `update_time`)
VALUES
(11001, '000000', 1001, 'POSITION', NULL, '生活顾问', '一线', 'INTERNAL', 1, NOW(), NOW()),
(11002, '000000', 1002, 'POSITION', NULL, '生活顾问', '一线', 'INTERNAL', 1, NOW(), NOW()),
(11003, '000000', 1003, 'POSITION', NULL, '生活顾问', '一线', 'INTERNAL', 1, NOW(), NOW())
ON DUPLICATE KEY UPDATE
`classification` = VALUES(`classification`),
`enabled` = VALUES(`enabled`),
`update_time` = NOW();
-- 知识库批量上传队列:上传秒回后由后台 worker 加工,支持单文件重试
CREATE TABLE IF NOT EXISTS `aihr_knowledge_upload_item` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',