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 '主键',
|
||||
|
||||
Reference in New Issue
Block a user