fix(personal): fail closed on ambiguous enterprise grants

This commit is contained in:
2026-07-12 16:01:52 +08:00
parent 8cf8da6899
commit c7756f86e2
6 changed files with 110 additions and 23 deletions
@@ -29,11 +29,12 @@ public class OrgSnapshotEnterpriseKnowledgeAccessPolicy implements EnterpriseKno
return Optional.empty();
}
try {
Optional<String> phone = userPhone(owner);
if (phone.isEmpty()) {
Optional<UserIdentity> userIdentity = userIdentity(owner);
if (userIdentity.isEmpty()) {
return denied(owner, "user_phone_missing");
}
Optional<OrganizationIdentity> organization = organization(owner.tenantId(), phone.orElseThrow());
Optional<OrganizationIdentity> 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<String> userPhone(PersonalOwner owner) {
List<String> phones = jdbcTemplate.query("""
SELECT phonenumber
private Optional<UserIdentity> userIdentity(PersonalOwner owner) {
List<UserIdentity> 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<Long> 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<OrganizationIdentity> organization(String tenantId, String phone) {
@@ -92,7 +111,7 @@ public class OrgSnapshotEnterpriseKnowledgeAccessPolicy implements EnterpriseKno
private List<Long> authorizedFragmentIds(String tenantId, OrganizationIdentity identity) {
String canonicalPosition = canonicalPosition(identity.positionName());
return jdbcTemplate.query("""
List<Long> 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<Long> boundedFragmentIds(String tenantId, List<Long> 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<EnterpriseKnowledgeGrant> 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) {
}
}
@@ -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<Long> 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<Long> 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<List<Object>> args = new ArrayList<>();
private String phone;
private String expectedTenant;
private List<Long> phoneUserIds = List.of(103L);
private Map<String, String> organization;
private List<Map<String, String>> organizations;
private List<Long> 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();
@@ -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");
+4 -4
View File
@@ -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`),
+1 -1
View File
@@ -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` 必须明确为个人或企业域。企业域授权只读取登录用户的 `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`。
@@ -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`