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();