From 21613532470295081538cf9dd377ef0c31b2ca62 Mon Sep 17 00:00:00 2001 From: let5sne Date: Sun, 12 Jul 2026 15:45:46 +0800 Subject: [PATCH] feat(personal): authorize enterprise knowledge from org ACL --- .../EnterpriseKnowledgeAccessPolicy.java | 5 +- ...apshotEnterpriseKnowledgeAccessPolicy.java | 152 +++++++++++ ...otEnterpriseKnowledgeAccessPolicyTest.java | 246 ++++++++++++++++++ .../personal/PersonalSchemaContractTest.java | 26 ++ backend/script/sql/aihr_knowledge_mysql8.sql | 31 +++ docs/API_INTEGRATION.md | 2 +- docs/DEV_SETUP.md | 2 + docs/个人AI助理阶段二专项TechSpec.md | 13 +- 8 files changed, 471 insertions(+), 6 deletions(-) create mode 100644 backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/personal/service/OrgSnapshotEnterpriseKnowledgeAccessPolicy.java create mode 100644 backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/personal/OrgSnapshotEnterpriseKnowledgeAccessPolicyTest.java diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/personal/service/EnterpriseKnowledgeAccessPolicy.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/personal/service/EnterpriseKnowledgeAccessPolicy.java index 69e11b0c..9b95d2bd 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/personal/service/EnterpriseKnowledgeAccessPolicy.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/personal/service/EnterpriseKnowledgeAccessPolicy.java @@ -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 { diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/personal/service/OrgSnapshotEnterpriseKnowledgeAccessPolicy.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/personal/service/OrgSnapshotEnterpriseKnowledgeAccessPolicy.java new file mode 100644 index 00000000..33fc2be4 --- /dev/null +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/personal/service/OrgSnapshotEnterpriseKnowledgeAccessPolicy.java @@ -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 authorize(PersonalOwner owner) { + if (owner == null || owner.userId() <= 0 || isBlank(owner.tenantId())) { + return Optional.empty(); + } + try { + Optional phone = userPhone(owner); + if (phone.isEmpty()) { + return denied(owner, "user_phone_missing"); + } + Optional organization = organization(owner.tenantId(), phone.orElseThrow()); + if (organization.isEmpty()) { + return denied(owner, "active_org_missing"); + } + OrganizationIdentity identity = organization.orElseThrow(); + List 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 userPhone(PersonalOwner owner) { + List 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 organization(String tenantId, String phone) { + List 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 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 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(); + } + } +} diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/personal/OrgSnapshotEnterpriseKnowledgeAccessPolicyTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/personal/OrgSnapshotEnterpriseKnowledgeAccessPolicyTest.java new file mode 100644 index 00000000..445633d5 --- /dev/null +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/personal/OrgSnapshotEnterpriseKnowledgeAccessPolicyTest.java @@ -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 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 sql = new ArrayList<>(); + private final List> args = new ArrayList<>(); + private String phone; + private String expectedTenant; + private Map organization; + private List fragmentIds = List.of(); + private boolean fail; + + @Override + public List query(String sql, RowMapper 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 List mapRows(RowMapper mapper, List> rows) { + List result = new ArrayList<>(); + for (int index = 0; index < rows.size(); index++) { + result.add(mapRow(mapper, rows.get(index), index)); + } + return result; + } + + private static T mapRow(RowMapper mapper, Map row) { + return mapRow(mapper, row, 0); + } + + private static T mapRow(RowMapper mapper, Map row, int rowNum) { + try { + ResultSet resultSet = mock(ResultSet.class); + for (Map.Entry 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); + } + } + } +} diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/personal/PersonalSchemaContractTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/personal/PersonalSchemaContractTest.java index bc704acc..030b383e 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/personal/PersonalSchemaContractTest.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/personal/PersonalSchemaContractTest.java @@ -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(); diff --git a/backend/script/sql/aihr_knowledge_mysql8.sql b/backend/script/sql/aihr_knowledge_mysql8.sql index 25217ef4..3f90944f 100644 --- a/backend/script/sql/aihr_knowledge_mysql8.sql +++ b/backend/script/sql/aihr_knowledge_mysql8.sql @@ -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 '主键', diff --git a/docs/API_INTEGRATION.md b/docs/API_INTEGRATION.md index d3e7225d..3cdd85b9 100644 --- a/docs/API_INTEGRATION.md +++ b/docs/API_INTEGRATION.md @@ -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 ` 与 `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`。 diff --git a/docs/DEV_SETUP.md b/docs/DEV_SETUP.md index 6fd1a7e0..141fcb4a 100644 --- a/docs/DEV_SETUP.md +++ b/docs/DEV_SETUP.md @@ -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: diff --git a/docs/个人AI助理阶段二专项TechSpec.md b/docs/个人AI助理阶段二专项TechSpec.md index b69dabc5..53990868 100644 --- a/docs/个人AI助理阶段二专项TechSpec.md +++ b/docs/个人AI助理阶段二专项TechSpec.md @@ -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:移动端闭环与试点