feat(personal): authorize enterprise knowledge from org ACL
This commit is contained in:
+1
-4
@@ -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 {
|
||||
|
||||
|
||||
+152
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
+246
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+26
@@ -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 '主键',
|
||||
|
||||
@@ -174,7 +174,7 @@ curl -fsS -X POST "$API_BASE/api/knowledge/doc/rebuild-vector-index" -H "Authori
|
||||
|
||||
个人知识接口统一位于 `/api/aihr/personal-assistant`,包括空间、资料列表与详情、文字/文件/URL 收藏、重试、删除、下载链接、检索、问答和会话。所有接口必须使用移动端短信登录返回的 `Authorization: Bearer <access_token>` 与 `clientid`,服务端从登录态注入 `tenant_id + owner_user_id`;请求体和查询参数不接受调用方指定 owner。
|
||||
|
||||
`POST /search` 与 `POST /ask` 的 `scope` 可选 `PERSONAL`、`ENTERPRISE` 或二者组合。返回引用的 `domain` 必须明确为个人或企业域;企业域只有在服务端企业知识授权策略返回显式 fragment allowlist 后才会参与 mixed 检索,未配置授权时 fail-closed。个人资料详情、下载、重试、删除、`itemIds` 检索均按 owner 过滤,不存在与越权统一返回 `PERSONAL_ITEM_NOT_FOUND`。
|
||||
`POST /search` 与 `POST /ask` 的 `scope` 可选 `PERSONAL`、`ENTERPRISE` 或二者组合。返回引用的 `domain` 必须明确为个人或企业域。企业域授权只读取登录用户的 `sys_user` 手机号、同租户在职 `aihr_org_snapshot` 和启用的 `aihr_knowledge_acl`,按 `TENANT / PROJECT / POSITION` 规则在 SQL 层生成 fragment allowlist;无组织身份或 ACL 时 fail-closed。客户端不能提交岗位、项目或 fragmentId 扩权。当前是阶段二组织快照消费,不表示阶段三北森实时对接完成。个人资料详情、下载、重试、删除、`itemIds` 检索均按 owner 过滤,不存在与越权统一返回 `PERSONAL_ITEM_NOT_FOUND`。
|
||||
|
||||
URL 收藏在任何网络请求前执行 SSRF 校验,私网、环回、链路本地和云元数据地址返回 `PERSONAL_URL_BLOCKED`。删除先进入 `DELETING` 并立即对详情和检索隐藏,再异步清理 Qdrant、片段和 OSS;重复删除返回同一个 `cleanupJobId`。
|
||||
|
||||
|
||||
@@ -198,6 +198,8 @@ curl -k -s https://peilian.njzhmj.top/h5/ | sed -n '1,20p'
|
||||
|
||||
个人知识使用独立 Qdrant collection,默认 `aihr_personal_knowledge`,payload 强制包含 `tenant_id`、`owner_user_id`、`item_id` 与 `captured_at`。对象存储固定使用 `personal-minio` 配置和私有 bucket `ruoyi-personal`,不修改企业资料使用的默认 `minio/ruoyi`。MySQL 表和专用 OSS 配置由 `backend/script/sql/aihr_personal_knowledge_mysql8.sql` 初始化;旧开发库先运行 `COMPOSE_PROJECT_NAME=wygj ./scripts/reset-dev-db.sh`。
|
||||
|
||||
企业范围问答另外依赖 `aihr_org_snapshot` 与 `aihr_knowledge_acl`。reset 会给企业 SOP `1001/1002/1003` 写入“一线生活顾问”POSITION ACL;手机号 `13900000103` 的组织快照岗位为物业管家,服务端按固定别名映射到生活顾问后获得 allowlist。普通新注册手机号没有组织快照,企业范围必须返回无权限。不要用手工手机号、客户端岗位参数或 TENANT ACL 绕过该默认拒绝;开放组织系统只负责刷新阶段二快照,北森实时组织/任职仍属阶段三。
|
||||
|
||||
空间与处理配额通过 `aihr.personal.*` 配置覆盖,包括空间字节配额、资料数量、单文件大小、抓取字节/超时、worker 与 cleanup 批量大小。不要把个人 collection 改回企业 `aihr_knowledge`。
|
||||
|
||||
启动依赖与后端后运行真实隔离 smoke:
|
||||
|
||||
@@ -124,10 +124,21 @@ org.dromara.aihr.knowledge.parse
|
||||
5. 个人资料转企业知识必须复制经审核后的脱敏版本,不能把个人 item 直接改成企业 scope。
|
||||
6. 自动化测试必须使用两个用户交叉访问,验证列表、详情、检索、向量和删除均返回不可见。
|
||||
|
||||
企业知识授权由唯一生产策略 `OrgSnapshotEnterpriseKnowledgeAccessPolicy` 负责:只用登录态
|
||||
`tenant_id + user_id` 查询本人 `sys_user.phonenumber`,再匹配同租户、在职的 `aihr_org_snapshot`,
|
||||
最后按 `aihr_knowledge_acl` 的 `TENANT / PROJECT / POSITION` 规则生成 fragment allowlist。岗位别名
|
||||
`生活顾问 / 物业管家 / 客服管家` 只在服务端归一化;无组织快照、离职、无启用 ACL、表缺失或数据库故障均默认拒绝。
|
||||
这里消费的是阶段二本地/定时同步快照,不代表阶段三北森实时对接已经完成。
|
||||
|
||||
## 4. 数据模型
|
||||
|
||||
初始化 SQL:`backend/script/sql/aihr_personal_knowledge_mysql8.sql`。
|
||||
|
||||
企业 ACL 独立位于 `backend/script/sql/aihr_knowledge_mysql8.sql` 的 `aihr_knowledge_acl`,不向个人表添加
|
||||
企业 scope。ACL 以租户和 knowledge 为边界,可按租户、项目或岗位授权,并保留 `INTERNAL / RESTRICTED`
|
||||
密级字段;没有启用 ACL 的企业知识默认不可见。当前 seed 仅将 SOP `1001/1002/1003` 明确授权给一线
|
||||
生活顾问岗位,没有全租户放开。
|
||||
|
||||
### 4.1 `aihr_personal_space`
|
||||
|
||||
每个租户内每名员工一条。
|
||||
@@ -518,7 +529,7 @@ src/services/personal-assistant.ts API 客户端
|
||||
|
||||
### M3:企业知识授权融合
|
||||
|
||||
- 企业知识权限过滤。
|
||||
- 企业知识权限过滤:组织快照 + 独立 ACL + SQL fragment allowlist 下推,默认拒绝。
|
||||
- 个人/企业/mixed 三范围切换和引用域标记。
|
||||
|
||||
### M4:移动端闭环与试点
|
||||
|
||||
Reference in New Issue
Block a user