fix(personal): fail closed on ambiguous enterprise grants
This commit is contained in:
+43
-11
@@ -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) {
|
||||
}
|
||||
}
|
||||
|
||||
+53
-3
@@ -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();
|
||||
|
||||
+7
-3
@@ -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");
|
||||
|
||||
@@ -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`),
|
||||
|
||||
@@ -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`
|
||||
|
||||
|
||||
Reference in New Issue
Block a user