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 index dfe4e8f3..dc14bfb0 100644 --- 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 @@ -29,11 +29,12 @@ public class OrgSnapshotEnterpriseKnowledgeAccessPolicy implements EnterpriseKno return Optional.empty(); } try { - Optional phone = userPhone(owner); - if (phone.isEmpty()) { + Optional userIdentity = userIdentity(owner); + if (userIdentity.isEmpty()) { return denied(owner, "user_phone_missing"); } - Optional organization = organization(owner.tenantId(), phone.orElseThrow()); + Optional organization = organization(owner.tenantId(), + userIdentity.orElseThrow().phone()); if (organization.isEmpty()) { return denied(owner, "active_org_missing"); } @@ -51,9 +52,9 @@ public class OrgSnapshotEnterpriseKnowledgeAccessPolicy implements EnterpriseKno } } - private Optional userPhone(PersonalOwner owner) { - List phones = jdbcTemplate.query(""" - SELECT phonenumber + private Optional userIdentity(PersonalOwner owner) { + List users = jdbcTemplate.query(""" + SELECT user_id, phonenumber FROM sys_user WHERE BINARY tenant_id = BINARY ? AND user_id = ? @@ -63,8 +64,26 @@ public class OrgSnapshotEnterpriseKnowledgeAccessPolicy implements EnterpriseKno 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(); + """, (rs, rowNum) -> new UserIdentity(rs.getLong("user_id"), trimmed(rs.getString("phonenumber"))), + owner.tenantId(), owner.userId()); + if (users.size() != 1 || users.get(0).userId() != owner.userId() || users.get(0).phone().isBlank()) { + return Optional.empty(); + } + UserIdentity identity = users.get(0); + List matchingUserIds = jdbcTemplate.query(""" + SELECT user_id + FROM sys_user + WHERE BINARY tenant_id = BINARY ? + AND phonenumber = ? + AND status = '0' + AND del_flag = '0' + ORDER BY user_id + LIMIT 2 + """, (rs, rowNum) -> rs.getLong("user_id"), owner.tenantId(), identity.phone()); + if (matchingUserIds.size() != 1 || matchingUserIds.get(0) != owner.userId()) { + return Optional.empty(); + } + return Optional.of(identity); } private Optional organization(String tenantId, String phone) { @@ -92,7 +111,7 @@ public class OrgSnapshotEnterpriseKnowledgeAccessPolicy implements EnterpriseKno private List authorizedFragmentIds(String tenantId, OrganizationIdentity identity) { String canonicalPosition = canonicalPosition(identity.positionName()); - return jdbcTemplate.query(""" + List fragmentIds = jdbcTemplate.query(""" SELECT DISTINCT f.id AS fragment_id FROM aihr_knowledge_acl a JOIN aihr_knowledge_info i @@ -103,6 +122,7 @@ public class OrgSnapshotEnterpriseKnowledgeAccessPolicy implements EnterpriseKno AND BINARY f.tenant_id = BINARY a.tenant_id WHERE BINARY a.tenant_id = BINARY ? AND a.enabled = 1 + AND a.classification = 'INTERNAL' AND ( a.access_scope = 'TENANT' OR (a.access_scope = 'PROJECT' AND a.project_code = ?) @@ -112,14 +132,23 @@ public class OrgSnapshotEnterpriseKnowledgeAccessPolicy implements EnterpriseKno AND (a.position_level IS NULL OR a.position_level = '' OR a.position_level = ?)) ) ORDER BY f.id ASC - LIMIT 200 + LIMIT 201 """, (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(); + return boundedFragmentIds(tenantId, fragmentIds); + } + + private List boundedFragmentIds(String tenantId, List fragmentIds) { + if (fragmentIds.size() > MAX_FRAGMENT_GRANTS) { + log.warn("enterprise_acl_denied tenant={} reason=fragment_limit_exceeded count={}", + tenantId, fragmentIds.size()); + return List.of(); + } + return fragmentIds; } private Optional denied(PersonalOwner owner, String reason) { @@ -152,4 +181,7 @@ public class OrgSnapshotEnterpriseKnowledgeAccessPolicy implements EnterpriseKno return !projectCode.isBlank() && !positionName.isBlank(); } } + + private record UserIdentity(long userId, String phone) { + } } 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 index 895582b3..7aa7d3c8 100644 --- 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 @@ -104,6 +104,18 @@ class OrgSnapshotEnterpriseKnowledgeAccessPolicyTest { assertFalse(jdbc.sql.stream().anyMatch(value -> value.contains("FROM aihr_knowledge_acl"))); } + @Test + void duplicateActiveUserAccountsForPhoneFailClosedBeforeOrganizationLookup() { + RecordingJdbcTemplate jdbc = fixture(); + jdbc.phoneUserIds = List.of(103L, 104L); + jdbc.fragmentIds = List.of(100101L); + + assertTrue(new OrgSnapshotEnterpriseKnowledgeAccessPolicy(jdbc) + .authorize(new PersonalOwner("000000", 103L, null)).isEmpty()); + assertFalse(jdbc.sql.stream().anyMatch(value -> value.contains("FROM aihr_org_snapshot"))); + assertFalse(jdbc.sql.stream().anyMatch(value -> value.contains("FROM aihr_knowledge_acl"))); + } + @Test void aclQueryEnforcesTenantProjectPositionAndTenantScopes() { RecordingJdbcTemplate jdbc = fixture(); @@ -115,6 +127,7 @@ class OrgSnapshotEnterpriseKnowledgeAccessPolicyTest { 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.classification = 'INTERNAL'")); 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'")); @@ -123,6 +136,21 @@ class OrgSnapshotEnterpriseKnowledgeAccessPolicyTest { jdbc.args.get(jdbc.args.size() - 1)); } + @Test + void restrictedClassificationAndCrossTenantAclRowsFailClosed() { + RecordingJdbcTemplate restricted = fixture(); + restricted.aclClassification = "RESTRICTED"; + restricted.fragmentIds = List.of(100101L); + assertTrue(new OrgSnapshotEnterpriseKnowledgeAccessPolicy(restricted) + .authorize(new PersonalOwner("000000", 103L, null)).isEmpty()); + + RecordingJdbcTemplate crossTenant = fixture(); + crossTenant.aclTenant = "999999"; + crossTenant.fragmentIds = List.of(100101L); + assertTrue(new OrgSnapshotEnterpriseKnowledgeAccessPolicy(crossTenant) + .authorize(new PersonalOwner("000000", 103L, null)).isEmpty()); + } + @Test void positionAliasesAreResolvedOnlyOnServer() { for (String position : List.of("生活顾问", "物业管家", "客服管家")) { @@ -164,9 +192,18 @@ class OrgSnapshotEnterpriseKnowledgeAccessPolicyTest { } @Test - void fragmentGrantIsStableDistinctAndCappedAtTwoHundred() { + void moreThanTwoHundredFragmentsRejectsEntireGrant() { RecordingJdbcTemplate jdbc = fixture(); - List ids = new ArrayList<>(LongStream.rangeClosed(1, 250).boxed().toList()); + jdbc.fragmentIds = LongStream.rangeClosed(1, 201).boxed().toList(); + + assertTrue(new OrgSnapshotEnterpriseKnowledgeAccessPolicy(jdbc) + .authorize(new PersonalOwner("000000", 103L, null)).isEmpty()); + } + + @Test + void twoHundredFragmentsAreAllowedWithStableDistinctOrdering() { + RecordingJdbcTemplate jdbc = fixture(); + List ids = new ArrayList<>(LongStream.rangeClosed(1, 200).map(value -> 201 - value).boxed().toList()); ids.add(1L); jdbc.fragmentIds = ids; @@ -176,6 +213,7 @@ class OrgSnapshotEnterpriseKnowledgeAccessPolicyTest { assertEquals(200, grant.allowedFragmentIds().size()); assertEquals(1L, grant.allowedFragmentIds().get(0)); assertEquals(200L, grant.allowedFragmentIds().get(199)); + assertTrue(jdbc.sql.get(jdbc.sql.size() - 1).contains("LIMIT 201")); } @Test @@ -203,9 +241,12 @@ class OrgSnapshotEnterpriseKnowledgeAccessPolicyTest { private final List> args = new ArrayList<>(); private String phone; private String expectedTenant; + private List phoneUserIds = List.of(103L); private Map organization; private List> organizations; private List fragmentIds = List.of(); + private String aclTenant = "000000"; + private String aclClassification = "INTERNAL"; private boolean fail; @Override @@ -219,7 +260,12 @@ class OrgSnapshotEnterpriseKnowledgeAccessPolicyTest { 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("AND phonenumber = ?")) { + return phoneUserIds.stream() + .map(id -> mapRow(rowMapper, Map.of("user_id", id))).toList(); + } + return phone == null ? List.of() : mapRows(rowMapper, + List.of(Map.of("user_id", phoneUserIds.get(0), "phonenumber", phone))); } if (sql.contains("FROM aihr_org_snapshot")) { if (organizations != null) { @@ -228,6 +274,10 @@ class OrgSnapshotEnterpriseKnowledgeAccessPolicyTest { return organization == null ? List.of() : mapRows(rowMapper, List.of(organization)); } if (sql.contains("FROM aihr_knowledge_acl")) { + if (!sql.contains("a.classification = 'INTERNAL'") + || !"INTERNAL".equals(aclClassification) || !args[0].equals(aclTenant)) { + return List.of(); + } return fragmentIds.stream().map(id -> mapRow(rowMapper, Map.of("fragment_id", id))).toList(); } return List.of(); 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 030b383e..9b172703 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 @@ -34,9 +34,13 @@ class PersonalSchemaContractTest { 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'")); + int seedStart = sql.indexOf("insert into `aihr_knowledge_acl`"); + assertTrue(seedStart > 0); + String aclSeed = sql.substring(seedStart, sql.indexOf(';', seedStart)); + assertFalse(aclSeed.contains("(`id`, `tenant_id`"), "ACL seed must not reserve fixed primary keys"); + assertTrue(aclSeed.contains("('000000', 1001, 'position'")); + assertTrue(aclSeed.contains("('000000', 1002, 'position'")); + assertTrue(aclSeed.contains("('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"); diff --git a/backend/script/sql/aihr_knowledge_mysql8.sql b/backend/script/sql/aihr_knowledge_mysql8.sql index 3f90944f..8d30d42d 100644 --- a/backend/script/sql/aihr_knowledge_mysql8.sql +++ b/backend/script/sql/aihr_knowledge_mysql8.sql @@ -146,11 +146,11 @@ ON DUPLICATE KEY UPDATE `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`) + (`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()) + ('000000', 1001, 'POSITION', NULL, '生活顾问', '一线', 'INTERNAL', 1, NOW(), NOW()), + ('000000', 1002, 'POSITION', NULL, '生活顾问', '一线', 'INTERNAL', 1, NOW(), NOW()), + ('000000', 1003, 'POSITION', NULL, '生活顾问', '一线', 'INTERNAL', 1, NOW(), NOW()) ON DUPLICATE KEY UPDATE `classification` = VALUES(`classification`), `enabled` = VALUES(`enabled`), diff --git a/docs/API_INTEGRATION.md b/docs/API_INTEGRATION.md index 3cdd85b9..3cfcf41b 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` 必须明确为个人或企业域。企业域授权只读取登录用户的 `sys_user` 手机号、同租户在职 `aihr_org_snapshot` 和启用的 `aihr_knowledge_acl`,按 `TENANT / PROJECT / POSITION` 规则在 SQL 层生成 fragment allowlist;无组织身份或 ACL 时 fail-closed。客户端不能提交岗位、项目或 fragmentId 扩权。当前是阶段二组织快照消费,不表示阶段三北森实时对接完成。个人资料详情、下载、重试、删除、`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。阶段二只允许 `INTERNAL` 密级,`RESTRICTED` 在账号密级映射完成前保持拒绝。客户端不能提交岗位、项目或 fragmentId 扩权。当前是阶段二组织快照消费,不表示阶段三北森实时对接完成。个人资料详情、下载、重试、删除、`itemIds` 检索均按 owner 过滤,不存在与越权统一返回 `PERSONAL_ITEM_NOT_FOUND`。 URL 收藏在任何网络请求前执行 SSRF 校验,私网、环回、链路本地和云元数据地址返回 `PERSONAL_URL_BLOCKED`。删除先进入 `DELETING` 并立即对详情和检索隐藏,再异步清理 Qdrant、片段和 OSS;重复删除返回同一个 `cleanupJobId`。 diff --git a/docs/个人AI助理阶段二专项TechSpec.md b/docs/个人AI助理阶段二专项TechSpec.md index 53990868..25d6d07e 100644 --- a/docs/个人AI助理阶段二专项TechSpec.md +++ b/docs/个人AI助理阶段二专项TechSpec.md @@ -137,7 +137,8 @@ org.dromara.aihr.knowledge.parse 企业 ACL 独立位于 `backend/script/sql/aihr_knowledge_mysql8.sql` 的 `aihr_knowledge_acl`,不向个人表添加 企业 scope。ACL 以租户和 knowledge 为边界,可按租户、项目或岗位授权,并保留 `INTERNAL / RESTRICTED` 密级字段;没有启用 ACL 的企业知识默认不可见。当前 seed 仅将 SOP `1001/1002/1003` 明确授权给一线 -生活顾问岗位,没有全租户放开。 +生活顾问岗位,没有全租户放开。阶段二尚无账号密级映射,因此只允许 `INTERNAL`;`RESTRICTED` 和未知密级 +继续默认拒绝,待显式账号密级授权映射完成后再开放。 ### 4.1 `aihr_personal_space`