From 22220ed980b194fb28ae8be4b61805316799d7d7 Mon Sep 17 00:00:00 2001 From: GuoQing Zhang Date: Mon, 2 Nov 2026 14:22:28 +0800 Subject: [PATCH 1/8] fix(knowledge): avoid concurrent retrieval on a shared async session --- .../services/knowledge_space_chat_service.py | 24 ++++----- ...t_knowledge_space_chat_service_retrieve.py | 52 +++++++++++++++++++ 2 files changed, 62 insertions(+), 14 deletions(-) diff --git a/src/backend/bisheng/knowledge/domain/services/knowledge_space_chat_service.py b/src/backend/bisheng/knowledge/domain/services/knowledge_space_chat_service.py index c64500bd78..6046568415 100644 --- a/src/backend/bisheng/knowledge/domain/services/knowledge_space_chat_service.py +++ b/src/backend/bisheng/knowledge/domain/services/knowledge_space_chat_service.py @@ -693,21 +693,17 @@ async def aretrieve_chunks( filters_by_kb[kb_id] = spec engine = self._retrieval_engine() - per_kb_results = await asyncio.gather( - *( - self._aretrieve_chunks_for_one( - engine, - kb_id, - query=query, - tag_names=(filters_by_kb.get(kb_id) or {}).get("tags") or [], - max_content=max_content, - ) - for kb_id in knowledge_base_ids - ) - ) - flattened: list[tuple[int, Document]] = [] - for chunks in per_kb_results: + # Version visibility queries share the request-scoped AsyncSession, + # which must not be used concurrently by multiple space retrievals. + for kb_id in knowledge_base_ids: + chunks = await self._aretrieve_chunks_for_one( + engine, + kb_id, + query=query, + tag_names=(filters_by_kb.get(kb_id) or {}).get("tags") or [], + max_content=max_content, + ) flattened.extend(chunks) flattened = flattened[:top_k] await engine.attach_document_update_time(flattened) diff --git a/src/backend/test/knowledge/test_knowledge_space_chat_service_retrieve.py b/src/backend/test/knowledge/test_knowledge_space_chat_service_retrieve.py index 96e810cb0c..ae387158d2 100644 --- a/src/backend/test/knowledge/test_knowledge_space_chat_service_retrieve.py +++ b/src/backend/test/knowledge/test_knowledge_space_chat_service_retrieve.py @@ -12,6 +12,7 @@ loop are now covered by ``test_retrieval_engine_extraction.py``. """ +import asyncio from unittest.mock import AsyncMock, MagicMock import pytest @@ -206,3 +207,54 @@ async def test_aretrieve_chunks_for_one_rejects_unsupported_type(monkeypatch): with pytest.raises(svc_mod.KnowledgeTypeNotSupportedError): await svc._aretrieve_chunks_for_one(MagicMock(), 5, query="q", tag_names=[], max_content=100) + + +async def test_multi_space_retrieval_does_not_overlap_request_scoped_version_queries(): + svc = _make_service() + query_in_progress = False + queried_spaces = [] + + async def find_non_primary_files(space_ids): + nonlocal query_in_progress + # AsyncSession cannot provision/use one connection from two tasks. + if query_in_progress: + raise RuntimeError("concurrent operations on the request-scoped session") + query_in_progress = True + try: + await asyncio.sleep(0) + queried_spaces.extend(space_ids) + return [] + finally: + query_in_progress = False + + svc.version_repo.find_non_primary_file_ids_by_knowledge_ids = AsyncMock(side_effect=find_non_primary_files) + + async def retrieve_space(engine, space_id, **kwargs): + await svc._visibility_service()._non_primary_ids(space_id) + return [(space_id, _doc(f"marker-{space_id}", document_id=space_id, document_name="source.txt", chunk_index=0))] + + svc._aretrieve_chunks_for_one = AsyncMock(side_effect=retrieve_space) + + result = await svc.aretrieve_chunks(query="markers", knowledge_base_ids=[7, 9]) + + assert queried_spaces == [7, 9] + assert [(space_id, doc.page_content) for space_id, doc in result] == [(7, "marker-7"), (9, "marker-9")] + assert not query_in_progress + + +async def test_multi_space_retrieval_does_not_skip_later_permission_denial_when_top_k_is_full(): + svc = _make_service() + + async def retrieve_space(engine, space_id, **kwargs): + if space_id == 9: + raise HTTPException(status_code=403, detail="space access denied") + return [(space_id, _doc("allowed", document_id=7, document_name="source.txt", chunk_index=0))] + + svc._aretrieve_chunks_for_one = AsyncMock(side_effect=retrieve_space) + + with pytest.raises(HTTPException) as exc: + await svc.aretrieve_chunks(query="markers", knowledge_base_ids=[7, 9], top_k=1) + + assert exc.value.status_code == 403 + assert [call.args[1] for call in svc._aretrieve_chunks_for_one.await_args_list] == [7, 9] + svc._knowledge_retrieval_engine.attach_document_update_time.assert_not_awaited() From 98f24ba1e4f768946dbc0ddc1fc6e72fed6db404 Mon Sep 17 00:00:00 2001 From: GuoQing Zhang Date: Mon, 2 Nov 2026 15:45:31 +0800 Subject: [PATCH 2/8] fix(vibe): report runtime status and current approval visibility --- .claude/skills/approval-module/SKILL.md | 6 +- ...ntime-review-design-review-2026-11-02.html | 1012 +++++++++++++++++ ...runtime-review-development-2026-11-02.html | 443 ++++++++ ...e-runtime-review-discovery-2026-11-02.html | 151 +++ ...runtime-review-spec-review-2026-11-02.html | 365 ++++++ .../code-review.md | 35 + .../design-review.md | 73 ++ .../115-vibe-runtime-review-fixes/design.md | 378 ++++++ .../discovery.md | 82 ++ .../e2e-checklist.md | 45 + .../spec-review.md | 48 + .../115-vibe-runtime-review-fixes/spec.md | 159 +++ .../115-vibe-runtime-review-fixes/tasks.md | 407 +++++++ .../verification.md | 111 ++ features/v3.0.0/release-contract.md | 21 + .../domain/schemas/current_visibility.py | 26 + .../app_publish_current_visibility_service.py | 114 ++ .../services/app_publish_scenario_handler.py | 16 + .../domain/services/app_query_service.py | 19 +- .../services/approval_center_service.py | 3 + .../approval_runtime_handler_factory.py | 12 + .../bisheng/common/errcode/knowledge.py | 5 + .../knowledge/api/endpoints/knowledge.py | 18 +- .../domain/schemas/knowledge_create_schema.py | 9 + .../services/knowledge_creation_policy.py | 47 + .../domain/services/knowledge_service.py | 11 +- .../permission/application/control_state.py | 43 + .../bisheng/permission/application/runtime.py | 37 + .../domain/repositories/grant_repository.py | 21 +- .../permission_subject_repository.py | 25 + .../services/f048_permission_subject.py | 52 + .../user/application/runtime_identity.py | 56 +- .../domain/repositories/runtime_identity.py | 27 +- .../test_f115_current_visibility.py | 125 ++ .../test_f115_instance_observation.py | 51 + .../test/approval/test_f115_current_detail.py | 100 ++ src/backend/test/e2e/helpers/cleanup.py | 120 +- .../test/e2e/test_e2e_f115_runtime_review.py | 140 +++ .../knowledge/test_f115_creation_entry.py | 57 + .../knowledge/test_f115_creation_policy.py | 66 ++ .../test/knowledge/test_f115_e2e_cleanup.py | 31 + .../permission/test_f115_action_sources.py | 100 ++ .../tenant/test_f115_visibility_subjects.py | 69 ++ .../test/user/test_f115_current_identity.py | 68 ++ src/frontend/client/src/api/approval.ts | 10 + .../approval/AppPublishDetailPanel.test.tsx | 10 +- .../approval/AppPublishDetailPanel.tsx | 46 +- .../approval/ApprovalDetailPanels.tsx | 8 +- .../src/components/approval/ApprovalPane.tsx | 4 +- .../approval/CurrentAppVisibility.test.tsx | 28 + .../approval/CurrentAppVisibility.tsx | 31 + .../client/src/locales/en/api_errors.gen.json | 1 + .../client/src/locales/en/translation.json | 17 + .../client/src/locales/ja/api_errors.gen.json | 1 + .../client/src/locales/ja/translation.json | 17 + .../src/locales/zh-Hans/api_errors.gen.json | 1 + .../src/locales/zh-Hans/translation.json | 17 + .../packages/locales/src/api_errors/en.json | 1 + .../packages/locales/src/api_errors/ja.json | 1 + .../locales/src/api_errors/zh-Hans.json | 1 + .../public/locales/en-US/api_errors.json | 1 + .../platform/public/locales/en-US/bs.json | 36 + .../public/locales/ja/api_errors.json | 1 + .../platform/public/locales/ja/bs.json | 36 + .../public/locales/zh-Hans/api_errors.json | 1 + .../platform/public/locales/zh-Hans/bs.json | 36 + .../platform/src/controllers/API/approval.ts | 10 + .../platform/src/controllers/API/hostedApp.ts | 3 +- .../ApprovalPage/CurrentAppVisibility.tsx | 32 + .../src/pages/ApprovalPage/ExceptionPanel.tsx | 9 +- .../ApprovalPage/useApprovalController.ts | 2 + .../ApprovalPage/useApprovalExceptions.ts | 39 +- .../BuildPage/hostedApp/tabs/PublishTab.tsx | 30 +- .../src/pages/KnowledgePage/KnowledgeFile.tsx | 8 +- .../src/pages/KnowledgePage/KnowledgeQa.tsx | 5 +- .../components/KnowledgeLibraryDialog.tsx | 18 +- .../components/QaLibraryDialog.tsx | 18 +- .../LogPage/runtime/RuntimeAuditPanel.tsx | 15 +- .../src/test/f115ApprovalVisibility.test.tsx | 29 + .../src/test/f115KnowledgeCreation.test.tsx | 47 + .../src/test/f115RuntimeReview.test.tsx | 54 + .../runtime_manager/api/platform_observe.py | 83 +- .../runtime_manager/executors/docker.py | 24 +- .../runtime_manager/executors/kubernetes.py | 25 +- .../runtime_manager/observation.py | 47 + .../tests/test_f115_observation.py | 177 +++ src/runtime-manager/tests/test_k8s_observe.py | 39 +- 87 files changed, 5630 insertions(+), 193 deletions(-) create mode 100644 docs/reviews/vibe-runtime-review-design-review-2026-11-02.html create mode 100644 docs/reviews/vibe-runtime-review-development-2026-11-02.html create mode 100644 docs/reviews/vibe-runtime-review-discovery-2026-11-02.html create mode 100644 docs/reviews/vibe-runtime-review-spec-review-2026-11-02.html create mode 100644 features/v3.0.0/115-vibe-runtime-review-fixes/code-review.md create mode 100644 features/v3.0.0/115-vibe-runtime-review-fixes/design-review.md create mode 100644 features/v3.0.0/115-vibe-runtime-review-fixes/design.md create mode 100644 features/v3.0.0/115-vibe-runtime-review-fixes/discovery.md create mode 100644 features/v3.0.0/115-vibe-runtime-review-fixes/e2e-checklist.md create mode 100644 features/v3.0.0/115-vibe-runtime-review-fixes/spec-review.md create mode 100644 features/v3.0.0/115-vibe-runtime-review-fixes/spec.md create mode 100644 features/v3.0.0/115-vibe-runtime-review-fixes/tasks.md create mode 100644 features/v3.0.0/115-vibe-runtime-review-fixes/verification.md create mode 100644 src/backend/bisheng/app_publish/domain/schemas/current_visibility.py create mode 100644 src/backend/bisheng/app_publish/domain/services/app_publish_current_visibility_service.py create mode 100644 src/backend/bisheng/knowledge/domain/schemas/knowledge_create_schema.py create mode 100644 src/backend/bisheng/knowledge/domain/services/knowledge_creation_policy.py create mode 100644 src/backend/bisheng/tenant/domain/repositories/permission_subject_repository.py create mode 100644 src/backend/test/app_publish/test_f115_current_visibility.py create mode 100644 src/backend/test/app_runtime/test_f115_instance_observation.py create mode 100644 src/backend/test/approval/test_f115_current_detail.py create mode 100644 src/backend/test/e2e/test_e2e_f115_runtime_review.py create mode 100644 src/backend/test/knowledge/test_f115_creation_entry.py create mode 100644 src/backend/test/knowledge/test_f115_creation_policy.py create mode 100644 src/backend/test/knowledge/test_f115_e2e_cleanup.py create mode 100644 src/backend/test/permission/test_f115_action_sources.py create mode 100644 src/backend/test/tenant/test_f115_visibility_subjects.py create mode 100644 src/backend/test/user/test_f115_current_identity.py create mode 100644 src/frontend/client/src/components/approval/CurrentAppVisibility.test.tsx create mode 100644 src/frontend/client/src/components/approval/CurrentAppVisibility.tsx create mode 100644 src/frontend/platform/src/pages/ApprovalPage/CurrentAppVisibility.tsx create mode 100644 src/frontend/platform/src/test/f115ApprovalVisibility.test.tsx create mode 100644 src/frontend/platform/src/test/f115KnowledgeCreation.test.tsx create mode 100644 src/frontend/platform/src/test/f115RuntimeReview.test.tsx create mode 100644 src/runtime-manager/runtime_manager/observation.py create mode 100644 src/runtime-manager/tests/test_f115_observation.py diff --git a/.claude/skills/approval-module/SKILL.md b/.claude/skills/approval-module/SKILL.md index 7c01b91840..8ba35ecb28 100644 --- a/.claude/skills/approval-module/SKILL.md +++ b/.claude/skills/approval-module/SKILL.md @@ -105,7 +105,7 @@ ApprovalCenterService.decide_task() | `approval/domain/services/approver_resolver.py` | 解析审批人来源 `direct_user` / `department_admin` / `tenant_admin` | `resolve_approvers_from_sources()`、**`resolve_tenant_admin_user_ids()`** | | `approval/domain/services/approval_seed_service.py` | **预置场景 seed(每租户)**:`DEFAULT_APPROVAL_SCENARIO_SEEDS` 三条 × 五行(scenario / route / flow / flow_version / node) | `seed_approval_scenarios_for_tenant()`、`seed_approval_scenarios_in_session()` | | `approval/domain/services/approval_registry.py` | 场景预置目录 + handler 注册表 | `with_default_presets()`、`register_handler()`、`get_handler()` | -| `approval/domain/services/approval_runtime_handler_factory.py` | 为 outbox 执行 / 多节点 advance 重新构造运行时 handler | `build_runtime_handler(scenario_code)` | +| `approval/domain/services/approval_runtime_handler_factory.py` | 为 outbox 执行 / 多节点 advance 重新构造运行时 handler;审批详情通过原阅读资格后追加场景当前事实 | `build_runtime_handler(scenario_code)`、`read_current_scenario_detail()` | | `approval/application/execution_source.py` | 业务侧只读审批投影 | `get_business_approval()` / `get_business_approvals()`(按 100 条分批,版本记录复用) | | `approval/domain/services/approval_notification_service.py` | 站内信统一封装 | `notify_user()` / `notify_users()` / `notify_admins()` | | `approval/domain/services/user_menu_access_service.py` | 菜单授权增删查,含父级菜单依赖自动补全 | `grant_menu_access()`、`revoke_menu_access()`、`ensure_application_allowed()` | @@ -120,6 +120,10 @@ ApprovalCenterService.decide_task() `approval/application/app_review.py::pending_app_reviews` 返回冻结的 tenant/app/version/instance/task 值对象;仓储 `app_review_repository.py` 只查询指定真实自然人、指定 app、实例和任务均 pending 的记录。该个人任务查询不继承页面管理视角,源码读取随后在验证过的任务租户内执行并恢复调用前上下文。服务账号不能借其归属人的审批任务,临时资格只覆盖待审版本和当前正式比较基线;普通长期源码读取改用 app edit,不变成长期授权。 +### 应用发布当前可见范围(F115) + +任务详情、个人实例详情、管理实例详情在原租户与审批阅读门禁通过后,调用 `read_current_scenario_detail()`。仅 `app_publish_request` 使用 Handler 的只读 `read_current_detail()`,其它场景不触发动态查询。新增同级 `current_visibility`;`detail_snapshot`、`payload_snapshot` 仍是冻结值,不回写。应用发布域核对实例绑定的 app/tenant,再经 F048 当前有效 `use` 来源和实时主体资格生成摘要;读取不完整时返回 `unavailable`,不误报“仅负责人可见”。此窄例外不授予长期 manage_permission、审批处理资格或其它资源能力。 + ### 四个场景 Handler | 文件 | 类 | diff --git a/docs/reviews/vibe-runtime-review-design-review-2026-11-02.html b/docs/reviews/vibe-runtime-review-design-review-2026-11-02.html new file mode 100644 index 0000000000..7676cd317a --- /dev/null +++ b/docs/reviews/vibe-runtime-review-design-review-2026-11-02.html @@ -0,0 +1,1012 @@ +F115 设计与自审评审稿
设计已确认 · 本地开发完成,待部署验收

2026-11-02 · 基线 497256d28 · 需求发现与正式规格均已确认 · 尚未修改业务代码

52 条 AC 已确认6 项设计决策24 项设计清单自审通过设计已确认,代码本地实现

Design:F115 应用运行信息、审批当前可见范围与知识库创建

+

关联:已确认 spec · 已确认 discovery。
+版本:v3.0.0;发版线 feat/3.0.0。
+最后更新:2026-11-02。
+状态:用户于 2026-11-02 单独确认设计;代码与本地定向验证已实现,真实部署 E2E 尚未执行;52 条规格已确认。本文区分固定基线现状、实施接线和远端部署,不将方案写成已部署事实。
+源码基线:497256d289793ce016df07cf8ab38f32988faca4,工作分支 codex/vibe-runtime-review-20261102。116/120 只读环境证据沿 discovery,不在设计阶段更新环境。

+

1. 目标与非目标

+

补齐当前实例可观测事实,明确模型调用用户筛选空态,在原审批阅读资格内显示当前有效访问授权,并统一本租管的新建知识库资格。沿用现有运行、权限、审批和知识库领域,不新增并列授权规则。

+

非目标沿 spec:不增加监控系统、不恢复 v1 控制、不改现有应用的 OBO 接线、不回填审计或历史审批、不迁移资源、不补写角色菜单和授权;知识库复制、发布审读/预览及其它平台能力不扩权。

+

2. 关键约束

+

遵循 constitution C1–C8、release-contract 和前后端各自 AGENTS;全局铁律不在本文重抄。

+
    +
  • 两个时间面:版本、发布说明、声明及审批决定属于冻结内容;可见范围是本次查询的当前授权,只作只读装饰,不写入历史快照。
  • +
  • 当前执行体:必须同时匹配应用、节点、操作和执行引用;K8s 额外校验 Pod UID,不能按名称猜测或选第一个容器。
  • +
  • 只读观测:复用 v2 执行器现有观察和应用探活;Docker 保持现有单次 HTTP 探活的 2 秒超时及判定口径,不增加后台任务、定时器或全新探活请求入口。
  • +
  • 完整性优先:只有完整授权与对象资格查询成功才能产生 owner_only=true。投影不就绪、目录服务失败、结果超过本次可完整读取的界限均返回显式不可确认,不输出未经确认的部分名单。
  • +
  • 有界读取:权限来源按既有 500 条游标页读取,本次摘要最多检查 5,000 条来源、最多 5 秒;任一界限触发则整体 unavailable,不把截断后的剩余对象当作不存在。该预算仅用于当前摘要,不改变 F048 通用完整授权目录。目录批量解析,不逐成员展开。
  • +
  • 创建资格不等于管理视角:新资源仍落实际自然人与其当前所属租户。新建表单固定打开时的所属租户,实际写入前重验,管理视角不会覆盖此值。
  • +
  • 不新增数据库表或列、配置顶层键、队列、权限动作、运行协议版本或监控依赖。MySQL/DM8 通过既有持久层与 dialect helpers 保持兼容。
  • +
+

3. 方案对比与选定

+

D1. 用现有执行器观测补齐运行事实

+
    +
  • 备选:只转发 Docker 原生 Health;从历史 AppInstance 补字段;新建定时采样系统;复用现有执行器观察并统一只读状态返回。
  • +
  • 选定:最后一种。在 Docker/K8s observe 的结果中补充当前应用容器的计数、启动时间和探活状态;v2 只读状态端点统一转换,后端仅保留公开字段。
  • +
  • 原因:116 的容器有实际启动时间但未设置原生 HEALTHCHECK;基线状态端点漏转时间,而执行器已有实际 HTTP/Ready 观察。历史 AppInstance 回退可能把旧实例信息当成当前值;新增采样系统超出 D1。
  • +
  • 重新考虑条件:后续明确要求长期监控、历史趋势或改变现有探活判定时,另立需求,不借本次显示修复改变发布就绪门槛。
  • +
+

D2. 模型用户查询保持原合同,只补空态与范围切换

+
    +
  • 备选:用负责人补用户;返回全租户人员;新增历史回填;保持真实记录查询并修前端状态。
  • +
  • 选定:保留现有候选和记录接口、范围校验、分页及真实 user/natural_person 过滤;使用现有共享 Select 的 emptyText/noMatchText/loadingText,在业务层区分失败和重试。
  • +
  • 原因:116/120 当前无应用限制时已返回真实用户候选,截图应用只有应用自身调用;未复现候选接口整体为空。补负责人会改变历史身份事实。现有 Select 已具备相应文案入口,无需改共享控件或候选数据结构。
  • +
  • 重新考虑条件:后续需要真实用户姓名快照、更多身份筛选或新的 OBO 数据采集,单独评估来源和历史兼容;不由本功能推测。
  • +
+

D3. 审批校验之后添加当前摘要,不覆盖冻结快照

+
    +
  • 备选:重建 detail_snapshot;让客户端调用通用授权列表;为审批人临时赋 manage_permission;在原详情读取通过后添加独立的 current_visibility。
  • +
  • 选定:最后一种。审批服务先完成原阅读校验,再经现有场景 Handler 工厂调用应用发布场景的只读补充方法;其它场景不增加业务行为。新字段与两个历史 snapshot 同级。
  • +
  • 原因:基线任务、个人实例及管理实例详情均直接返回保存的 snapshot。直接覆盖会让冻结内容与当前事实混淆;通用授权列表要求长期 manage_permission,不适合已确认的窄审批例外。
  • +
  • 重新考虑条件:另有场景提出相同的动态展示需求后再评估共性。本次只增加一个可选场景补充钩子,不建立新的审批视图框架。
  • +
+

D4. 当前范围读取 F048 有效动作来源,身份目录由业务侧补充

+
    +
  • 备选:按 model 名称/level 判断;业务侧查询 Grant/OpenFGA;枚举全租户每个人做 Check;由 F048 提供可信服务端有效动作来源读取,再批量核对对象资格。
  • +
  • 选定:最后一种。权限应用端口提供目标资源 use 的有效来源;复用 CURRENT Catalog、当前投影和继承规则。现有租户主体目录新增批量只读解析,返回有效、已失效、不可确认三类结果和显示名称。
  • +
  • 原因:已有可信服务端 list_effective_direct_user_ids_by_model 说明来源读取不必借管理页身份,但它只支持直接人员和指定模型,不能直接满足本功能。通用 list_grants 的管理门禁保持;新增窄读取不提供 HTTP 入口、不伪造 owner/超管。
  • +
  • 重新考虑条件:权限模块正式提供同等语义的完整 action 来源公共协议时改用该协议;不保留两套来源判定算法。
  • +
+

D5. 仅放开知识库新建,不修改全局创建菜单

+
    +
  • 备选:给租管批量加菜单;将 create_knowledge 全局并入租管菜单;仅改按钮;知识库新建使用独立资格策略并复用原创建流程。
  • +
  • 选定:最后一种。前端区分 canCreateLibrary 与 canCopyLibrary;后端 Web 新建入口执行本功能的创建资格策略,复制仍保留原菜单及源资源读取门禁。
  • +
  • 原因:两类知识库页面当前将新建资格复用于复制;后端 create/copy 都要求原菜单。单改页面或全局加菜单会分别导致继续拒绝或顺带扩大复制权限。
  • +
  • 重新考虑条件:以后明确要求统一所有资源的创建/复制能力时再做菜单策略调整,不把本次例外推广到其它资源。
  • +
+

D6. 复用真实 Web 身份校验,并把表单租户作为前置条件

+
    +
  • 备选:信任登录响应的 is_child_admin;按页面选中管理租户写入;修改共用 KnowledgeCreate 的所有调用者;Web 新建请求携带表单预期租户,服务端按当前真实身份检查。
  • +
  • 选定:最后一种。仅 Web create 请求增加可选 expected_tenant_id;两个新建对话框均固定发送打开时的 leaf_tenant_id。服务器不把该参数当作目标租户,只用于与已验证所属租户比较;写入者仍为真实登录自然人。
  • +
  • 原因:JWT/表单可能跨越人员迁租户和资格变化,页面管理视角又不同于人员归属。已有 user.application.runtime_identity.resolve_web_runtime_identity 可验证多租户下的人员、唯一有效租户及会话版本;新建用途在同一应用模块增加薄入口处理单租户,不改延迟运行任务原有严格合同,不新增身份系统。
  • +
  • 兼容:旧请求缺少该字段时,以其经验证 JWT 租户作预期值,仍须与实时身份匹配;新页面显式固定预期值,可拒绝表单打开后换会话/换租户的提交。共享知识库请求及开放 API 不因此新增菜单门禁。
  • +
  • 重新考虑条件:未来正式支持跨租户代创建,须另审创建归属和防陈旧合同,不能放宽此处比较。
  • +
+

4. 固定基线与本地实施接线

+

4.1 固定基线现状

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
链路497256d28 的真实接线缺口
运行状态app_query_service.get_instance → orchestrator_client.status → v2 platform_observe.status → 当前绑定检查Docker 只读原生 Health;两形态漏启动时间;缺失计数默认零;后端找不到执行体时回退旧 AppInstance
Docker/K8s 观察executors/docker.py::observe 已发单次应用探活;executors/kubernetes.py::_observed 已核对 Pod Ready 与 EndpointSlice UID/IP返回未覆盖完整显示事实;K8s 找不到 app 状态时求和为零
模型用户候选runtime_audit_query_service.candidates → runtime_audit_repository.statement/candidates;API candidates/visitor?kind=model数据范围与真实身份过滤已存在;Select 使用通用空文案,失败提示与空列表可能同时出现
审批详情approval_center_service.get_task_detail/get_instance_detail 先校验资格,再返回 detail_snapshot/payload_snapshot;app Handler 仅复制空的 visibility_snapshot缺当前授权展示,客户端仅解析冻结 snapshot
权限来源F048ResourcePermissionApi.list_grants 和 runtime list_permission_sources_page 要求 manage_permission;control_state.load_source_page 处理 CUSTOM/INHERIT 与 CURRENT 来源不能让普通审批阅读者直接调用通用 roster;无按 action 返回当前人员/组/部门摘要的端口
对象目录TenantPermissionSubjectDirectory.canonical_source/display_names 已验证或显示对象现有人员 canonical 校验关注有效成员,不能直接当作“人员未停用且名称完整”的完整摘要证明
知识库knowledge.create_knowledge 先断言菜单,再调用同步创建;Doc/QA 两页面相同条件控制新建和复制租管路由可见与新建资格不一致;表单没有固定创建租户前置条件
+

基线注释中的历史 owner-only 或长期 v1 说明不能覆盖 F114 当前合同。orchestrator_client.status 中仍出现 /v1/ 字符串,但 _request/_v2_request 强制 v2 并改写路径,不能据此重新接通 v1。

+

4.2 运行状态:沿当前绑定观察

+

发布页既有实例查询 → 原 F114 状态阅读门禁 → 后端只读状态 RPC → 当前绑定校验 → 执行器 observe → 统一事实 → 显示。

+
    +
  1. platform_observe.status 只读加载当前 app_id/generation/operation_id/exec_ref/node_id。Docker 校验 app、操作、节点及 v2 管理标签;K8s 校验应用、操作、节点、Pod UID 和应用容器名 app。
  2. +
  3. 将显示所需事实补入执行器观察结果。Docker 从同次精确容器 inspect 取得 State.StartedAt 和 RestartCount,健康仍由已有应用 HTTP 探活形成;原生 Health 可作诊断,不能替代统一的应用就绪语义。K8s 仅从匹配 app 的 containerStatuses 取得重启与当前 state.running.startedAt,当前状态为 terminated 时可取其 startedAt;不读 initContainer 或 lastState 冒充当前。
  4. +
  5. observe 原 ready/running/target 语义供生命周期继续使用;新增 probe_state 区分已成功、已失败、尚无结果,不改变原 <500 的 HTTP 判定和发布就绪规则。探活返回失败和探活基础设施不可访问是不同结果;无应用地址、状态缺失不得默认为探活成功。
  6. +
  7. 状态返回前再次确认绑定的 generation、operation、exec_ref(K8s 含 UID)未变化;变化则报告暂不可确认,刷新再读,不能混合新旧事实。不修改平台绑定或尝试自愈。
  8. +
  9. Docker Restarting、K8s Pending/ContainerCreating 或当前操作 starting 且没有完成判定时显示启动中。已有明确探活失败则为异常;Ready/探活成功为正常;缺少有效判定为未知。执行体已停止时 phase 为 stopped,不显示仍健康。
  10. +
  11. 无绑定与绑定的执行体丢失是正常可解释的两种只读结果,见 §4.5。调用错误、归属不匹配、平台/集群不可用走原 16121 故障链。后端 get_instance 不再用旧 AppInstance 的健康、计数、启动时间补“当前值”。
  12. +
  13. 启动时间统一输出带时区的 ISO-8601 UTC;空值、Docker 未启动零日期、无效时间为 null。计数只有明确的非负整数才有效,布尔/缺失/非法值为 null;真实 0 保留。前端先验证时间,再复用 displayTimestamp.formatTimestamp 转浏览器本地时区,不改全局时间工具。
  14. +
+

4.3 访问用户:只改状态与查询范围生命周期

+

当前具体租户与筛选条件 → 原候选 API → 真实自然人记录候选 → 选择后原记录 API。

+
    +
  • CandidateFilter 继续通过 controllers/API/runtimeAudit.ts 获取候选,不新增数据库查询、候选字段或历史身份来源。
  • +
  • 空搜索 + 查询成功 + 空候选:显示“当前范围没有访问用户记录;应用自身调用不代表自然人访问”。有搜索 + 成功空候选:显示无匹配用户。加载中使用 Select loading;失败时使用失败文案和重试按钮,空提示不得同时宣称无记录。
  • +
  • emptyText/noMatchText/loadingText 覆盖该过滤器,不改变共享 Select;真实 ID 原样用于过滤,不把 app/self ID 转成人名。
  • +
  • kind/具体租户/应用/时间等范围变化后重置该过滤器搜索与不再适用的 visitor 选择;请求 key 含完整新范围,不使用旧范围 placeholder 数据。读取途中切换范围的旧响应只能留在旧 key,不回写新状态。
  • +
  • 候选继续使用后端的同一授权范围及其它过滤条件;访客候选无需扩展成全租户人员。保留现有分页,列表和导出仍使用原冻结筛选合同。
  • +
+

4.4 审批摘要:先取得阅读资格,再读取当前授权

+

个人任务/个人申请/管理详情 → 原审批阅读校验 → app 发布 Handler 只读补充 → 业务核对 app/tenant → F048 action 来源 → 业务目录核对 → current_visibility。

+

A. 原阅读门禁与绑定

+
    +
  • 在 get_task_detail、get_instance_detail 的原租户、任务/申请归属或管理资格检查之后调用补充方法。失败的原鉴权不得进入补充逻辑,不能用 app.publish 代替审批阅读资格。
  • +
  • 通过 approval_runtime_handler_factory 为 app 场景取得新 Handler,调用新增可选 read_current_detail;无此方法的其它场景不增加任何查询。注册和调用均在服务层,不在 endpoint 跨模块导入。
  • +
  • 审批模块创建进程内的已验证阅读上下文,包含 instance、scenario、tenant、business_resource_id、当前阅读者和读取用途;不是 HTTP 可提交的对象,不存储凭据,不注册新对外 endpoint。
  • +
  • 发布场景的新增 app_publish_current_visibility_service.py 从该上下文取得应用 ID,核对保存的业务资源绑定及当前应用租户;若 payload 存在 app_id,必须与业务绑定一致。审批申请人、客户端传的 app_id 或历史 owner 名称不能替代绑定检查。
  • +
  • 正常目标由 app 业务适配器生成 VerifiedPermissionTarget;不伪造负责人或超管 actor。个人审批沿所属租户;管理详情仅允许已通过原管理门禁的具体租户。应用删除单独映射为 deleted;跨租户/绑定不一致不返回对象名单。
  • +
+

B. F048 的窄只读来源端口

+
    +
  • 在 permission.application 增加 read_effective_action_sources(target, action) 的可信服务端端口,内部委派 F048 runtime。它不替调用方判断审批资格,不接受客户端“已授权”标记;仅供已验证业务目标和前置业务阅读资格后的服务端调用,首个调用点限定为上段应用发布补充服务。
  • +
  • 复用 runtime 当前 Catalog/Store/model pin、_require_current_target 和权限内部 load_source_page;选取当前有效且 action_codes 含 use 的模型,不按名称或 level 推断。每页读取在权限持久层完成,业务方不查询 Grant 或 OpenFGA。
  • +
  • CUSTOM 取本地有效来源;INHERIT 复用既有最近自定义祖先来源与本资源受保护创建者语义,不引入第二套继承逻辑。目前 app 固定 CUSTOM,仍为可复用端口保留现有继承合同的配对验证。
  • +
  • 必须验证目标及实际涉及祖先投影 CURRENT、版本一致,并在完整分页结束后复核版本与 Catalog 未变化。PROJECTING/FAILED_CLOSED、游标不前进、未知模型或不完整来源均不能变成完整空集合;超预算返回不可确认。
  • +
  • 通用 list_grants/list_permission_sources_page 的 manage_permission 保持原样。新端口不包含授权写入、完整 model/Grant 元数据、编辑能力或来源秘密,不提供给通用 HTTP roster 使用。
  • +
+

C. 名称与有效资格

+
    +
  • 经现有 get_grant_subject_directory 取得业务侧目录,由 TenantPermissionSubjectDirectory 增加批量只读解析方法。新增 SQL 只放所属领域 Repository,权限模块仅依赖应用协议,不导入用户/部门/应用 ORM。
  • +
  • 多租户开启时人员必须未停用且只有一个有效当前人员租户,与应用租户一致;关闭时按有效自然人/tenant=1 语义验证,不要求额外成员行。user 应用模块提供批量 read_current_person_facts(user_ids),只输出当前人员事实,不要求也不制造这些人员的会话;Web 创建身份入口复用这些事实并额外验证真实会话。已明确失效的人员来源排除。部门/组需确认仍存在且属于该租户;批量读取失败、人员所属不能唯一确定或无法确认名称时,整个摘要 unavailable。
  • +
  • 组与部门保留 userset 含义:部门可注明含下级,用户组可注明仅管理员;不展开成员,也不承诺组内每个人都可以访问。按 (type,id,userset_relation,include_children) 去重,不重复同一来源的显示。
  • +
  • 服务账号来源跳过普通访问人员列表;管理员隐式访问只显示统一说明。发现当前合同之外、不能用人员/部门/组准确表达的有效来源(如历史 tenant 来源)时不得丢弃后宣称 owner-only,返回不可确认并记录安全原因。
  • +
  • 负责人当前资格使用真实当前人员和 F048 use 判定,不仅看 protected creator 行或历史快照;该人员失效则不补列“负责人可见”。构造其 PermissionActor 时只使用该人员当前有效成员、真实全局/租户管理员事实(原身份应用入口及 is_tenant_admin 查询),不调用可能返回当前请求阅读者缓存的 resolve_permission_actor 来代验另一人;也不临时给予管理员字段。单独查询负责人事实不等于代其执行业务。有效 owner 若不在普通来源列表则仅为展示去重补入,不补写授权。
  • +
  • 全部成功后才计算 owner_only:负责人有效,且不存在负责人以外的有效普通访问对象。完整成功但负责人无资格且无其它对象显示“当前没有有效普通访问对象”。
  • +
+

D. 失败隔离与页面

+
    +
  • detail_snapshot/payload_snapshot 原样返回;当前摘要是独立字段。过往空占位不回填,后续新审批也不把当前范围写入冻结快照。
  • +
  • 只读补充是明确的非关键展示步骤:其内部依赖异常记录完整服务端异常并返回 sanitized unavailable,不吞成成功空名单;原阅读鉴权和场景绑定检查不放进该降级捕获。未知程序异常同样留错误日志,前端看到不可确认,不泄露异常正文。
  • +
  • 客户端 api/approval.ts 扩展详情类型,AppPublishDetailPanel 只从 current_visibility 读取该区域,其余区域继续读 detail_snapshot;字段缺失的旧服务端显示“暂不可确认”,不回退旧快照冒充当前。
  • +
  • 打开详情重新查询,提供当前详情刷新回调;终态也读当前摘要。平台异常列表已有管理详情在 ExceptionPanel/useApprovalExceptions 中缓存,补充只读区域并在展开/刷新时重读当前管理详情,缓存按具体租户与 instance 隔离,不将终态缓存永久视作当前。
  • +
  • client/platform 各自使用本应用 API 包装与组件组合,不跨 SPA 复制状态或直接导入另一应用组件。平台只新增小型摘要区域,不复制整个客户端审批卡。
  • +
+

4.5 关键对外字段

+

实例状态(原 GET /api/v1/apps/{app_id}/instance,新增/收窄字段含义)

+
{
+  "instance_id": "current-execution-ref",
+  "phase": "running",
+  "health": "healthy",
+  "restart_count": 0,
+  "started_at": "2026-10-30T10:33:15.363Z",
+  "last_probe_at": "2026-11-02T02:00:00Z",
+  "observation_state": "current"
+}
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
字段类型与约束消费规则
phase沿既有 pending/building/starting/running/unhealthy/stopped/failed应用业务状态不由此改变;观察失败不伪装已删除
healthhealthy / unhealthy / starting / nullnull 显示未知;非法旧值也显示未知,不原样露英文
restart_count非负整数或 null类型改为 nullable;禁止 ?? 0
started_at/last_probe_at带时区 ISO-8601 或 null无有效值显示未知/尚未启动;不输出零日期或用当前查询时刻冒充启动时间
observation_statecurrent / no_execution / execution_missingno_execution:无绑定,phase stopped、instance_id null、三项事实 null;execution_missing:绑定有引用但执行体丢失,phase unhealthy、保留引用、事实 null。真正依赖故障返回错误
+

manager /v2/apps/{app_id}/status 使用相同语义,保留内部 app/generation 信息,后端白名单只增 observation_state。AppNotFound 回退不再读取旧实例事实;新 manager 对“无执行体”给出上述明确结果,旧 manager 的模糊 404 只显示无法确认,不能恢复历史事实。旧服务端缺少 observation_state 时新前端将三项事实作为尚未确认的新合同信息显示未知,不把旧默认零值或历史回退当作已验证当前事实;应用业务状态仍独立显示。

+

审批详情的同级增量

+
{
+  "current_visibility": {
+    "state": "available",
+    "checked_at": "2026-11-02T02:00:00Z",
+    "owner_only": false,
+    "owner": {"id": "849", "name": "负责人示例", "eligible": true},
+    "subjects": [
+      {"type": "user", "id": "849", "name": "负责人示例"},
+      {"type": "department", "id": "60", "name": "部门示例", "userset_relation": "subtree_member", "include_children": true},
+      {"type": "user_group", "id": "12", "name": "用户组示例", "userset_relation": "admin", "include_children": false}
+    ],
+    "admin_implicit_access": true,
+    "reason": null
+  }
+}
+
+
    +
  • state=available 仅代表完整确认;owner_only 为布尔。owner 是当前负责人信息,不能从冻结名称推断有效资格。
  • +
  • state=deleted|unavailable 时 owner_only=null、owner=null、subjects=[];空数组不能在前端解释成仅负责人。reason 仅为固定安全值:application_deleted、application_unavailable、authorization_unavailable;不返回 SQL、FGA 响应或身份目录详情。
  • +
  • 新前端始终提示“当前可见范围,授权变更即时生效,不影响审批”及管理员隐式资格说明。已有审批同意/拒绝按钮、审读及预览入口继续遵循原能力,不由当前摘要推导。
  • +
  • JSON 示例只是结构示意,不包含真实凭据或对示例 ID 的权限承诺。
  • +
+

其它接口

+
    +
  • GET /api/v1/app-runtime-audit/candidates/visitor?kind=model:请求/响应不变,仍为 items[{id,label,deleted_or_revoked}],has_more;记录与导出合同不变。
  • +
  • POST /api/v1/knowledge/create:Web 专用请求在既有字段外增加可选正整数 expected_tenant_id。它不是落库字段,不接受由此指定任意租户;Doc/QA 两对话框均发送打开时的所属租户。
  • +
  • 三个审批详情入口:/api/v1/approval/my-tasks/{task_id}、/api/v1/approval/instances/{instance_id}、/api/v1/approval/admin/instances/{instance_id} 只增加同级字段,不改原 snapshot;已核对 approval_user.py 的 router 与路径,不新设别名。
  • +
+

4.6 知识库新建接线与写入前重验

+

Doc/QA 表单固定所属租户 → Web create endpoint → 当前 Web 身份/创建资格 → 原校验 → 写入前再验 → 原创建/投影/索引流程。

+
    +
  1. 新增 knowledge_creation_policy.py,作为 Web 新建专用领域资格策略,消费真实登录会话的服务器引用,并调用 user.application.runtime_identity 新增的 resolve_web_creation_identity 薄入口。多租户开启时复用既有 resolve_web_runtime_identity 的唯一有效成员与租户校验;关闭时由同一 user 应用模块按有效人员、会话期限及 token_version 验证并固定 tenant=1,不要求旧单租户人员凭空补一条 UserTenant。新增读取仍在 user 所属 Repository,不放业务 SQL 到策略服务。最后比较 expected/JWT/实时所属租户;身份不确定、租户停用或会话失效按原身份错误拒绝,既有 runtime 身份函数语义不改。
  2. +
  3. 同一真实自然人需具备以下之一:原有效超管/创建菜单资格;或当前经 permission.application.is_tenant_admin 确认的本租户管理员资格。部门管理员和界面 is_child_admin 不成为后端授权来源。依赖故障不能静默授予租管例外。
  4. +
  5. 新建资格传递的是服务端创建的短生命周期上下文(人员/所属租户/会话版本/预期租户),不是持久对象或客户端 capability。endpoint 调用已有异步 KnowledgeService.acreate_knowledge,仅 Web 路径携带此上下文。
  6. +
  7. 原名称、embedding、租户与其它输入检查后,在 acreate_knowledge 进入 acreate_knowledge_base 之前重验该上下文和当前资格。失败时没有业务行、索引、FGA Grant 或后续任务。开放 API/其它内部调用不携带该 Web 上下文,继续原通道资格,不能被新增菜单检查误拦。
  8. +
  9. 整个 Web 创建使用既有 tenant_execution_scope(verified_tenant) 配合 strict_tenant_filter(),退出即恢复 ContextVar,不能把它误写成接受 tenant 参数的 strict 过滤函数。用户 ID 与租户 ID 仍由服务器覆盖为已验证自然人及其所属租户;客户端 user_id、管理范围和预期租户不能成为写入 authority。继续原创建者投影、失败补偿、索引初始化和审计;不添加菜单或角色。
  10. +
  11. 前端页面 canCreateLibrary=原有菜单/超管资格 OR 当前 is_child_admin;canCopyLibrary=原有菜单/超管资格,再叠加现有源读取条件。表单在打开时固定当前 leaf_tenant_id,不是 useAdminScope 的租户;该值尚未加载时不提交,刷新身份信息,只有服务端确认单租户模式时才固定 1。资格变化时按钮可能等待用户信息刷新,但实际写入始终拒绝过期资格。
  12. +
  13. expected 与当前所属租户不一致使用 §6 的专用前置条件错误,提示刷新表单;不要误用管理视角错误或悄悄改写到新租户。
  14. +
+

4.7 模块职责与影响面

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
现有/拟新增模块做什么不做什么
runtime-manager executors/*、api/platform_observe.py精确执行体观察、nullable 事实及统一状态不恢复控制 v1、不触发修复、不持久化第二份应用主数据
backend app_query_service.py、orchestrator_client.py原阅读门禁、公开字段、区分故障和无执行体不调用 Docker/K8s SDK、不回填旧实例值
platform PublishTab、hostedApp.ts状态、未知、零与有效时间展示不按 UI 猜测健康、不改变全局时间语义
platform RuntimeAuditPanel.tsx原查询、独立状态提示与范围重置不改历史账本、SDK、身份推断或共享 Select 视觉
approval 服务与 Handler 工厂原鉴权之后分派只读补充不认识 F048 Grant 或修改审批决定/路由
拟新增 app_publish 当前摘要服务核对 app/tenant/instance 绑定、协调 F048 来源与目录、输出小 DTO不写 snapshot、不授予权限、不直接查权限表
permission.application 端口与 runtime/control_state现有 Catalog/投影/继承下的 action 来源与完整性不依赖审批、用户或应用业务 ORM,不暴露新的 HTTP roster
tenant 主体目录及所属 Repository批量当前对象资格与名称不展开所有成员、不替代 F048 动作判定
knowledge Web 创建策略及原异步创建流程当前身份/租户和创建资格、写前重验不更改复制/开放通道资格、角色菜单或资源归属
user.application.runtime_identity 与所属 Repository复用当前人员事实,新增批量读取和 Web 新建用途薄入口;兼容关闭多租户不新增身份来源、不为负责人制造会话、不改变原运行/预览严格入口
client 审批面板 / platform 异常详情各自渲染当前范围与重查;其余字段仍冻结不在两个 SPA 之间混用状态、API 包装或页面组件
+

三语键分别落各应用现有 locale;新增业务错误对应 packages/locales/src/api_errors/{zh-Hans,en,ja}.json。实施时同步 approval-module skill 的详情 API 和场景补充接线说明,不更改审批人解析规则。

+

4.8 本地实现补充(2026-11-02)

+
    +
  • user.application.runtime_identity.read_current_person_facts 及 resolve_web_creation_identity 已接线;新建用途区分所属租户变化,原 runtime/preview 身份入口未改。
  • +
  • F048 SqlPermissionControlState.source_read_stamp 经仓储读取继承链的模式/Grant版本,read_effective_action_sources 完整分页后再次复核模式与Catalog。业务摘要在名称、负责人资格查完后再读一次来源,变化返回 unavailable。
  • +
  • AppPublishCurrentVisibilityService 由原审批门禁后的 app Handler 调用,超时预算5秒;目标 app/tenant 校验先于删除态和目录读取。DTO 使用 userset_relation(含 subtree_member),两端按成员、管理员、含下级分别显示。
  • +
  • 新建 endpoint 只使用验证过的真实所属租户,async create 在模型和重名检查后、首个写入前重验会话及资格;Web 专用 expected_tenant_id 不扩展共享 KnowledgeCreate。
  • +
  • 管理异常页重复展开、显式刷新均重查,旧异步响应用序号丢弃;管理租户切换沿既有 bisheng:management-scope-changed 全页重载清空内存。客户端真实父层为 ApprovalPane.tsx,刷新仍调用原详情 API。
  • +
  • 知识库表单缺少所属租户时阻止提交并提示刷新重开,不推测为1;服务端单租户 user/info 已有确认的1语义时才可发送1。新建与复制条件已分开。
  • +
  • manager 状态采用显式 no_execution/execution_missing/current;已停止执行体不保留失败探活相位,归属变化或平台资源读取失败不伪装成执行体丢失。K8s专用只读observe_status仅将精确Pod 404识别为丢失;原observe生命周期入口不变,Service/Endpoint等依赖失败仍不可确认。
  • +
  • 未新增表、权限动作或外部依赖。新增10993及三语产物已落地;E2E脚本仅显式开启后访问真实环境,创建另需写入开关,清理校验精确名称、人员和租户。
  • +
+

5. 已知坑与处理

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
#事实若忽略会怎样处理处
1有运行容器不等于配置了原生 Health;平台已有独立应用探活继续显示空健康,或把 Running 冒充健康manager observe/status,D1
2K8s app 状态缺失时现有 sum 默认为 0把未观测到数据当成真实零K8s _observed 与 nullable 映射
3后端无实例回退会读旧 AppInstance旧时间和健康出现在已替换实例上app_query_service.get_instance
4v1 字符串在只读客户端内被强制改成 v2为修显示重新接通已退役控制协议orchestrator_client._v2_request,保持 v2
5模型应用自身调用没有自然人 subject;候选目前已有真实数据补 owner 会篡改审计含义审计仓储保持;过滤器补空态
6授权模型名称/level 不等于当前 action_codes误把只有 edit 的对象当访问者F048 action 来源端口
7承载源的投影中间态、分页截断不能当完整空集合错误宣称“仅负责人可见”CURRENT/版本前后校验、有界失败显式 unavailable
8现有 canonical_source 对人只核成员行,display_names 只装饰名称已停用或迁租户人员仍列为有效主体目录新增批量资格解析,不直接复用为完整证明
9草稿/下线不清空授权;负责人可能仍记在历史单但已失去资格首发永远查不到范围,或无效负责人被认定可见app 业务目标与真实 owner use 分离
10部门 subtree、用户组 admin 与整个对象成员含义不同文案把仅部分 userset 授权夸大成全部成员DTO 保留 relation/include_children,前端标注
11异常进度按 exception ID 缓存详情授权变化后仍永久显示旧范围useApprovalExceptions 按 scope/instance 重查
12新建条件同时控制复制,页面租户也不等于创建归属一次补按钮造成复制扩权或写到所选租户Doc/QA 条件分离,表单 expected tenant + Web 策略
13同步与异步知识库创建、开放通道共用底层创建逻辑将 Web 菜单门禁加到 SDK 或重复创建流程仅 Web 入口带上下文,复用 acreate_knowledge 与原底层
14运行任务身份函数要求有效 UserTenant;旧单租户用户未必有该行复用后把原来可创建的人全拒绝user 应用模块新增用途入口,关闭多租户时固定 1;原 runtime 严格合同不改
15resolve_permission_actor 优先返回当前请求 actor查负责人时错误使用阅读者的超管/普通身份负责人按自身事实构造 actor,不能借请求缓存代验
+

6. 对外契约与依赖

+

6.1 提供与兼容

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
提供项形式/消费者兼容与限制
当前运行事实原 manager v2 status、平台 instance API字段增量;计数 nullable。manager/backend/frontend 一起升级后才算运行信息交付;旧字段缺失表达未知,不保留错误回退
current_visibility原三个审批详情,client 面板和 platform 管理详情同级增量;旧客户端可忽略,但不会获得本功能新显示;新客户端对旧后端显示未知,绝不把旧 snapshot 冒充当前
read_effective_action_sourcespermission.application 可信服务端协议需要业务验证目标与既有前置阅读资格;仅摘要用途首个调用点,通用 roster 管理门禁不变
read_current_detailapp 发布 Handler 可选补充只读,非 app 场景没有补充方法时返回无增量;不改 resolver/outbox
expected_tenant_idWeb knowledge/create 请求增量只作前置条件,不是跨租户创建能力;旧调用以已验证 JWT 租户作预期,不改变共享开放 API 请求
知识库 Web 创建策略原 endpoint 与已有异步创建服务不扩大复制与其它通道;前置失败无创建副作用,成功沿原投影/索引/审计
+

6.2 错误与展示状态

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
情形合同
manager/平台/集群不可用或执行归属不匹配复用 AppOrchestratorUnavailableError 16121,保留安全 reason;不转换成无应用
原审批无阅读资格原 ApprovalRequestPermissionDeniedError 18101;在任何摘要查询前拒绝
授权投影/目录失败或摘要超预算当前区域 state=unavailable 与固定 reason;日志留异常,冻结详情保持;不新增独立错误码
应用已删除当前区域 state=deleted,保留合法可读历史;不恢复应用
当前 Web 身份失效/无创建资格沿原身份及权限错误;前端统一请求层处理
新建预期所属租户与实时身份不一致拟新增 KnowledgeCreationScopeChangedError 10993,文案明确所属租户已变化,请重新打开表单;本次基线全部错误码扫描未占用。仅此一条新业务码,实施前重扫;若已被其它变更占用,选未占用码并同步本文与三语,不能复用冲突码
+

10993 登记在 knowledge 现有模块及版本契约,不新占模块;/api/v1 沿原业务错误 envelope,不擅自修改全站 HTTP 状态。未新增密钥、凭据、敏感字段或真实账号密码。

+

6.3 依赖与风险

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
依赖当前用途变化风险
当前应用绑定及 manager 执行器当前事实与安全边界exec_ref/UID 失配或旧 manager 不能证明当前值,必须未知/错误,不能降级旧绑定
Kubernetes Pod/Ready/EndpointSlice;Docker inspect 和已有 HTTP 探活精确当前应用容器API 失败不得等同负探活;新的容器名或 readiness 口径需同步观察测试
F048 CURRENT Catalog/Store/model/投影action 与完整来源pin 不一致、重发布/投影中必须不可确认;本功能不运行修复或迁移
主体目录、人员唯一当前成员与租户状态名称、对象资格、创建身份目录失败与已失效不同,不能用名称存在推断资格;业务层批量解析保持租户上下文;单租户关闭模式固定 1,不要求额外成员行
审批个人与管理阅读门禁窄只读例外前置资格不能将摘要服务直接暴露到只知道 ID 的请求,也不把 admin 进度资格变成同意权限
原知识库异步创建及投影补偿、embedding 回退复用实际创建不复制创建算法;新策略不得影响开放 API 默认模型或复制路径
共享 Select / 各应用现有 API、i18n、时间工具文案与交互不改共享组件视觉、不新增状态库;两个 SPA 不混用,三语同时交付
MySQL/DM8、OpenFGA、Redis、现有索引服务原平台依赖无新增服务或本地权威存储,无 DDL;DM8 兼容由审查及中央环境回归验证,不伪称本机已实测
+

7. 定向验证与可观测

+

7.1 本地验证策略(实施后执行)

+

只覆盖本功能改变的行为和具体风险,不运行仓库全量测试。新增 backend 测试放所属 test/<module>/;没有业务代码的设计阶段不跑业务测试。

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
层必要验证
manager 单元/接口Docker 有/无 Health 的真实探活、启动/异常/未知、0/正数/null、未启动日期;K8s app/init 区分、Ready/Endpoint UID、缺 containerStatuses;停止/丢失/切换绑定与查询无写副作用
backend 状态只读字段和 nullable、依赖错误 vs 无绑定、拒绝旧 AppInstance 数据回退、原状态阅读权限不变
审计后端/前端原真实身份/授权范围配对;app-self 与人混合时候选/筛选;空、搜索空、加载失败重试;切租户和过滤条件后的旧响应不污染
permission/目录use 动作与 model 名称分离、直接与继承、projection/catalog 切换、分页完整性/预算、停用迁租户、对象失效/服务失败、userset 不展开、未知 tenant 来源不误报 owner-only
审批服务与页面先鉴权后补充;有任务无长期 manage_permission 可读摘要但通用 roster 拒绝;进行中/终态、个人/管理三入口,owner-only/多类对象/负责人失效/删除/故障;snapshot 深度相等、其它三个审批场景不增加摘要调用
知识库无菜单租管 Doc/QA 成功;普通无菜单拒绝/有菜单成功;部门管理不新增资格;新建与复制分离;迁租户/停用/撤资格/会话变化前置拒绝;关闭多租户且无成员行仍保持原创建资格;原 embedding/索引与创建者投影不绕过
三语与静态变更文件 lint/typecheck/相关组件测试、三语键及错误码冲突检查;不因修文案引入全量回归
+

后续测试命令以明确文件选择执行,例如:

+
# 在 src/backend 中,仅指定本功能新增/受影响用例;不传整个 test/。
+uv run pytest test/app_runtime/test_app_query_service_entry.py test/app_governance/test_runtime_audit_query.py
+# 新增的摘要、Web 创建资格用例在任务阶段列出精确文件名。
+# manager 在 src/runtime-manager 中选择 v2 状态与 K8s 观察用例,不跑已退役 v1 测试作交付证明。
+python -m pytest tests/test_operations_v2.py tests/test_k8s_observe.py
+
+

这些是待实施验证入口,不是本轮已运行的记录;最终命令、环境、数量和失败基线记 verification。

+

7.2 必需真实 E2E 与操作步骤

+

真实验收仍是 SDD 必需步骤,但本轮只确认设计,不自动授权环境服务更新或业务写入;镜像、质量门禁及账号准备后按用户另行授权执行一次本功能定向验收。

+
    +
  1. 核对 116 的 backend/worker/platform/client 与 runtime-manager 实际镜像和本次提交;由用户授权更新相关服务后再测,不将旧 F114 manager 当作新状态合同的证明。
  2. +
  3. 使用专用 e2e-f115 人员、租户、应用与可清理知识库,不修改 gzx01 或三个存量应用归属;凭据经安全环境注入,会话仅进程内。
  4. +
  5. 在 [116:3001]/build/apps/{test_app} 核对健康、重启次数、浏览器本地启动时间与精确容器事实一致;用专用应用验证停止/无执行体;K8s 用具备同版本部署的专用环境验证,未授权或未部署则标未验,不冒充 116 已验证双形态。
  6. +
  7. 在 /log 的模型调用查询中,用真实 OBO 自然人调用与应用自身调用配对;检查候选、过滤与切范围,用无记录范围验证提示,不修补现有示例应用或历史日志。
  8. +
  9. 在 /workspace/settings/my-tasks、my-requests 及 /approval 的测试审批详情,依次读取仅负责人、增人员/部门/组、撤销/失效;刷新后变化但版本/说明/声明/决策快照不变。用有阅读资格但无 app.manage_permission 的人员做正向摘要和反向通用 roster 验证。
  10. +
  11. 本租管无创建菜单时在 /filelib 新建文档和 QA 库;源读取和创建菜单不足时复制仍拒绝。专用人员表单打开后迁租户或撤资格,提交不产生旧/新租户资源;普通有/无菜单及超管归属规则配对。
  12. +
  13. 清理仅本轮已创建的测试对象,并记录未清理原因;不停止、迁移、重建无关实例。审批引擎其它场景仅做本次补充钩子“不调用”合同验证,不改通用 resolver,因此不跑无关全场景业务回归。
  14. +
+

7.3 日志与发布核对

+
    +
  • manager 记录 app/exec_ref/operation/node/UID、观察分类及安全失败原因,不记录应用环境变量值。
  • +
  • app 发布摘要日志记录 instance/app/tenant、检查结果/耗时、来源数与安全 reason;失败带异常,不输出完整授权目录到日志。不得把当前摘要日志作为历史授权变更审计的替代品。
  • +
  • 知识库拒绝记录人员/预期租户/经验证所属租户及失败类型;成功继续现有创建审计,不记录会话引用的 token 或密钥。
  • +
  • 发布次序:先上能识别“新字段缺失=未知”的兼容 platform/client,再上 manager 与 backend;避免新 backend 的 null 计数被旧 UI 的 ??0 显示成零。知识库 UI 与 Web create 增量配套,全部镜像到位才进行交付验收。无 DDL、无数据迁移;回滚代码不会重写数据,但旧 UI 不提供本次新展示,旧后端不会识别新资格。部署和回滚均须明确服务范围。
  • +
+

7.4 AC 追溯

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
规格范围设计落点
AC-01–AC-10D1、§4.2、§4.5 实例状态、§7.1 manager/backend
AC-11–AC-21D2、§4.3、§7.1 审计
AC-22–AC-27D3/D4、§4.4 A–C、§4.5 当前摘要
AC-28–AC-31§4.4 C–D、§4.5 available/不可确认区分与冻结保持
AC-32–AC-38§4.4 A/D、§6.1、§7.1 三入口和反向权限验证
AC-39–AC-44D5/D6、§4.6、§6.2、§7.1 写前资格
AC-45–AC-50D5、§4.6 原菜单/复制/通道/模型保持
AC-51、AC-52§2、§4.7、§6、§7.1–§7.3
+

8. 不投入项与重审条件

+
    +
  • 长期监控、重启历史趋势、统一探活门槛:本轮只补当前事实;后续有明确运维需求再立项。
  • +
  • 用户姓名审计快照和历史访问者回填:现有日志没有事实就不造数据;新身份采集由独立 SDK/OBO 需求推进。
  • +
  • 超大授权范围的完整分页审批浏览:本次超过预算表达不可确认,不增加新通用目录 UI;真实遇到大规模场景再审只读分页与身份一致性。
  • +
  • 资源迁移/移交、管理员代创建到任意租户、知识库复制扩权:均未授权,不作为创建异常的兜底。
  • +
  • 通用审批 resolver、路由、outbox 与其它场景的当前授权展示:不在本次改动;若未来修改,需要按审批 skill 重新界定跨场景验证。
  • +
+

修订历史

+ + + + + + + + + + + + + + + +
日期改动触发原因
2026-11-02初版设计,52 条 AC 追溯;区分既有现状与拟实施方案用户确认正式规格并要求进入设计;设计尚待单独确认
+

| 2026-11-02 | 用户确认后按44个原子任务落地;补源版本双读、userset_relation、真实父层、Nullable运行事实与创建前重验;本地门禁和真实E2E分开记录 | 代码接线核对与定向验证;未改已确认AC或授权环境更新 |

+
+ +

F115 设计自审记录

+

阶段更新:用户已于2026-11-02单独确认本设计,随后已实施;下文保留设计确认前的自审结论,开发与部署状态以verification.md为准。

+

日期:2026-11-02。对象:design.md。方式:同一代理按 sdd-review/references/design-checklist.md 的 24 项清单自审,非独立代理复审。

+

结论

+

LGTM

+

设计可提交用户评审;本结论不代替用户单独确认设计,也不表示业务代码、镜像或真实环境已验证。spec 的 52 条 AC 已获用户确认,本轮没有更改其验收要求。

+

本轮自审已修正的问题

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
项观察到的问题已落到设计
R1把原运行任务的唯一 UserTenant 身份前置直接用到 Web 新建,会破坏部分旧单租户创建行为D6、§4.6:同一 user 应用模块提供新建薄入口;关闭多租户固定 1,不要求补成员行;原 runtime 严格合同不变
R2用请求上下文的 actor resolver 查询负责人,可能取到审批阅读者身份§4.4 C、§5:负责人使用自身真实事实,明确不得借用请求 actor 缓存或强设超管
R3单纯采用 grant 来源会遗漏失效人员、投影中间态或分页截断,错误产生 owner-only§4.4 B–C:CURRENT/版本/Catalog 前后复核、目录三态、预算失败显式 unavailable;非支持来源不能静默忽略
R4backend 先返回 null 时旧 UI 仍会 ??0§4.5、§7.3:先兼容前端识别未知,再升级运行层/后端,整组镜像到位后验收
+

24 项检查

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
#检查结论证据
1目标可在 1–3 句话说明PASS§1 定义四条链路的一致性修复
2非目标明确PASS§1、§8 保留不迁移、不回填、不扩其它能力边界
3特有约束PASS§2 仅定义执行体、两时间面、完整性预算、创建所属租户等
4Constitution 引用及门禁PASS引用 C1–C8,不重抄铁律;具体核对见下节
5有关键决策PASSD1–D6 覆盖运行、审计、审批、F048 和创建
6每项有备选/选定/原因PASSD1–D6 均记录被否方案及证据
7重审触发条件PASS每项都有重新考虑条件
8理由有可核对事实PASS116 只读证据、基线实际观察/快照/创建复用路径及既有协议
9输入到输出可快速理解PASS§4.2、§4.3、§4.4、§4.6 四条数据流
10字段与格式明确PASS§4.5 实例状态、摘要 DTO、空/失败状态与 Web 创建前置字段
11模块做/不做完整PASS§4.7 职责表,权限模块不依赖业务 ORM,两个 SPA 不混用
12现状描述与既有合同一致PASS§4.1 固定源码基线;方案均标为拟实施,未把旧 owner-only/v1 注释当当前合同
13有反直觉事实PASS§5 记录 15 条
14坑有后果PASS每条有若忽略的后果
15坑有处理落点PASS每条标具体模块/函数或设计小节
16输出合同充分PASS§6.1 HTTP 增量、内部应用端口与数据类型
17输入依赖充分PASS§6.3 覆盖绑定、集群/容器、权限、身份、审批和既有存储;无新增系统二进制或服务
18依赖风险明确PASS§6.3 每项说明哪些变化使其失效
19单元/集成/E2E 策略PASS§7.1 按边界选测试,§7.2 真实验收;不跑全量
20可操作手动验证PASS§7.2 给出环境镜像核对、专用身份、页面、配对操作与清理;凭据不入文档
21不投入项与理由PASS§8,含超大范围、历史采集、复制扩权与跨租户代创建
22修订记录PASS初版记录日期、触发与待确认状态,未宣称 Feature 完成
23与 spec 一致PASS§7.4 覆盖 AC-01–AC-52,合同只读例外和创建归属保持
24偏差同步PASStasks 尚未创建,无实施偏差;自审修订均在设计内更新,未倒改已确认 AC
+

Constitution Check

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
条款结论本设计核对
C1PASS状态编排留 manager,backend 不持编排 SDK;新 SQL 在所属 Repository,服务层场景调用不跳 endpoint
C2PASS无 DDL、新数据库对象或方言 SQL;沿既有持久层,未伪称已在本机实测 DM8
C3PASS具体租户及创建所属租户复核,使用既有执行 scope/严格过滤;身份批量读取在所属仓储并只按已绑定人员标识读取
C4PASS有效动作、投影与继承由 F048 提供;通用 roster 仍要求 manage_permission;审批只读例外先验原资格,没有自建授权或伪装 owner
C5PASS仅预留现有 knowledge 段未占用的 10993,实施前重扫并同步三语;其它情况沿原错误或显式展示状态
C6PASS无凭据、密钥、真实登录密码或新持久 token;示例均非敏感结构
C7PASSHTTP 仍经各自 API 包装与组件查询,不放 store 直连 HTTP
C8PASS无新本地权威状态或跨进程文件;当前摘要只读查询,不存第二份授权主数据
+

结构与追溯检查

+
    +
  • spec 的 AC 定义仍为 52 个连续唯一编号;设计 §7.4 分组覆盖全部编号,未增加或减少 AC。
  • +
  • 三个审批详情路由已核对实际 router;新端口/薄入口在设计中标明为拟新增,不伪称当前已有。
  • +
  • 设计代码块为合法 JSON/sh 示例,文档链接、章节引用及 HTML 导航在生成评审页时作静态核对。
  • +
  • 本轮只更新 SDD 文档、版本契约登记和 HTML 评审稿,未运行业务测试或全量测试;未更新 116、120、189。
  • +
+

下一步与暂停依据

+

依据 AGENTS.md §6、SDD-Guide §3,正式设计须用户单独确认后才可拆任务和编码。当前停在设计确认点,后续真实部署 E2E 仍须按明确环境授权执行。

\ No newline at end of file diff --git a/docs/reviews/vibe-runtime-review-development-2026-11-02.html b/docs/reviews/vibe-runtime-review-development-2026-11-02.html new file mode 100644 index 0000000000..c5ce1ca823 --- /dev/null +++ b/docs/reviews/vibe-runtime-review-development-2026-11-02.html @@ -0,0 +1,443 @@ +F115 开发与定向验证记录
按用户指令先交付发版线 · 真实部署验收待执行

2026-11-02 · 基线 497256d28 · 规格与设计已确认 · 开发及验收状态见正文

F115 开发与定向验证记录

+

日期:2026-11-02。分支 codex/vibe-runtime-review-20261102;源码基线 497256d289793ce016df07cf8ab38f32988faca4。规格52条及设计均获用户分别确认。本轮未部署;不运行全量测试。

+

本次 Git 交付安排

+

用户在本地开发与验收缺口已披露后,明确要求“提交推送 feat/3.0.0 分支”。按该指令先提交本功能、合入最新远端发版线并推送,保留目标分支后续增强。真实部署 E2E 及既有类型门禁问题仍按下表记录,不将 Git 交付视为部署验收完成。本次不更新环境或存量资源。

+

本地结果

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
范围实际结果证据/限制
后端新增链路与直接相关知识库回归63 passed;5个真实E2E用例默认skippedpytest实际组合运行;非空业务断言、拒绝先于名单、原子创建补偿及开放API兼容
manager Docker/K8s只读观察71 passedtest_f115_observation.py + test_k8s_observe.py;fake外部平台/执行器,非真实部署
Platform组件和直接审计回归19 passed / 4 files运行状态、候选失败/空/切范围、知识库两种入口与旧表单、审批重查
Client审批组件/父层相关断言22 passed / 3 files现有Jest受未构建canvas与ESM p-limit启动问题阻断;临时Vitest兼容副本仅替换测试全局名称/模块定位和JSX运行配置,执行真实产品组件及同一断言。没有修改仓库runner或依赖安装
前端 lintPASS按根脚本检查两个SPA与共享包
i18n与错误码产物PASScheck-i18n;locales build/check,三语源和6个生成文件
前端类型门禁未全绿;无新增诊断同一基线/同一依赖对照:Platform23条、Client134条;依赖缺失、旧图标/API等既有报错。本轮曾新增一处测试fixture类型错误,已修正并重新验证
Ruff无新增问题修改文件与git HEAD同配置对照;旧文件剩余问题按基线登记,新文件通过。所有修改Python执行format及未定义名检查
架构守卫无VIOLATIONknowledge endpoint旧database/models导入仅RULE-3既有WARNING
真实部署 E2E未执行5个用例只作离线收集/默认skip;未访问或写入116/189。必须依e2e-test及design §7.2在明确授权和部署后执行
+

可复现命令

+

工作目录分别为worktree的 src/backend、src/runtime-manager、src/frontend/platform。Python复用主工作区现有venv;config只指定既有本地文件路径,不复制或输出凭据。pytest使用 -q -p no:cacheprovider --tb=short --disable-warnings。

+
pytest test/app_runtime/test_f115_instance_observation.py test/user/test_f115_current_identity.py test/tenant/test_f115_visibility_subjects.py test/permission/test_f115_action_sources.py test/app_publish/test_f115_current_visibility.py test/approval/test_f115_current_detail.py test/knowledge/test_f115_creation_policy.py test/knowledge/test_f115_creation_entry.py test/knowledge/test_f115_e2e_cleanup.py test/knowledge/test_knowledge_create_atomicity.py test/knowledge/test_v2_filelib_unified.py test/e2e/test_e2e_f115_runtime_review.py
+pytest tests/test_f115_observation.py tests/test_k8s_observe.py
+vitest run src/test/f115RuntimeReview.test.tsx src/test/f115KnowledgeCreation.test.tsx src/test/f115ApprovalVisibility.test.tsx src/test/runtimeAudit.test.tsx
+pnpm lint
+pnpm typecheck
+pnpm check-i18n
+node packages/locales/scripts/build.mjs --check
+
+

临时日志位于 /private/tmp/f115-*.log;兼容运行及基线只存在临时目录,没有提交对依赖或测试环境的改写。

+

task-review(L1)

+

44个原子任务逐项检查命名/分层、响应序列化、SQL归属与严格租户、权限应用端口、两SPA/i18n、敏感信息、设计同步。实现任务配对用例及修改文件与tasks对应;基础设施和测试任务按skill的差异化清单检查。代码项PASS;前端旧类型/Ruff基线与真实E2E门禁为显式备注,不能记作远端已验收。

+
    +
  • T001–T003:10993未冲突、Web专用DTO不扩展共享创建目标、三语产物一致。
  • +
  • T004–T008:真实当前绑定/UID、HTTP事实、nullable/0及UTC归一;旧观察404不回退历史AppInstance。
  • +
  • T009–T016、T041:实时人员资格、批量目录、CURRENT及完整分页/版本复核;真实owner资格与阅读者分离,普通审批人不获得manage_permission。
  • +
  • T017–T019:三类详情均在原守卫后追加;非app场景无动态查询,两个snapshot不写回。
  • +
  • T020–T023:准备和写前重验,严格所属租户;copy/开放API不增加菜单门禁,原创建补偿保留。
  • +
  • T024–T033、T043:前端状态与真实字段一致,表单固定leaf、不以当前管理视角写入;管理异常页重查与序号拒绝旧响应。
  • +
  • T034–T038、T042:Client当前范围只读、typed userset、失败/删除/owner-only与冻结区域分开,父层真实API刷新。
  • +
  • T039、T044:专用E2E准备、显式启用和精确清理护栏;非测试名称与跨租户清理用例拒绝。
  • +
  • T040:记录本页和L2结论,完整实现52条目标的本地接线,不声称52条均在真实环境验收。
  • +
+

AC追溯

+

下表中的“本地”指单元/组件/相关回归与代码路径核对;所有真实部署项均待验,具体步骤见 e2e-checklist.md。不把测试任务写过覆盖编号当成真实验收证据。

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
AC本地证据入口真实环境
AC-01manager test_f115_observation/test_k8s_observe;backend instance;Platform f115RuntimeReview待部署验收
AC-02manager test_f115_observation/test_k8s_observe;backend instance;Platform f115RuntimeReview待部署验收
AC-03manager test_f115_observation/test_k8s_observe;backend instance;Platform f115RuntimeReview待部署验收
AC-04manager test_f115_observation/test_k8s_observe;backend instance;Platform f115RuntimeReview待部署验收
AC-05manager test_f115_observation/test_k8s_observe;backend instance;Platform f115RuntimeReview待部署验收
AC-06manager test_f115_observation/test_k8s_observe;backend instance;Platform f115RuntimeReview待部署验收
AC-07manager test_f115_observation/test_k8s_observe;backend instance;Platform f115RuntimeReview待部署验收
AC-08manager test_f115_observation/test_k8s_observe;backend instance;Platform f115RuntimeReview待部署验收
AC-09manager test_f115_observation/test_k8s_observe;backend instance;Platform f115RuntimeReview待部署验收
AC-10manager test_f115_observation/test_k8s_observe;backend instance;Platform f115RuntimeReview待部署验收
AC-11既有审计repository可信身份过滤核对;runtimeAudit及f115RuntimeReview组件待部署验收
AC-12既有审计repository可信身份过滤核对;runtimeAudit及f115RuntimeReview组件待部署验收
AC-13既有审计repository可信身份过滤核对;runtimeAudit及f115RuntimeReview组件待部署验收
AC-14既有审计repository可信身份过滤核对;runtimeAudit及f115RuntimeReview组件待部署验收
AC-15既有审计repository可信身份过滤核对;runtimeAudit及f115RuntimeReview组件待部署验收
AC-16既有审计repository可信身份过滤核对;runtimeAudit及f115RuntimeReview组件待部署验收
AC-17既有审计repository可信身份过滤核对;runtimeAudit及f115RuntimeReview组件待部署验收
AC-18既有审计repository可信身份过滤核对;runtimeAudit及f115RuntimeReview组件待部署验收
AC-19既有审计repository可信身份过滤核对;runtimeAudit及f115RuntimeReview组件待部署验收
AC-20既有审计repository可信身份过滤核对;runtimeAudit及f115RuntimeReview组件待部署验收
AC-21既有审计repository可信身份过滤核对;runtimeAudit及f115RuntimeReview组件待部署验收
AC-22action_sources/current_identity/visibility_subjects/current_visibility/current_detail;两端CurrentAppVisibility待部署验收
AC-23action_sources/current_identity/visibility_subjects/current_visibility/current_detail;两端CurrentAppVisibility待部署验收
AC-24action_sources/current_identity/visibility_subjects/current_visibility/current_detail;两端CurrentAppVisibility待部署验收
AC-25action_sources/current_identity/visibility_subjects/current_visibility/current_detail;两端CurrentAppVisibility待部署验收
AC-26action_sources/current_identity/visibility_subjects/current_visibility/current_detail;两端CurrentAppVisibility待部署验收
AC-27action_sources/current_identity/visibility_subjects/current_visibility/current_detail;两端CurrentAppVisibility待部署验收
AC-28action_sources/current_identity/visibility_subjects/current_visibility/current_detail;两端CurrentAppVisibility待部署验收
AC-29action_sources/current_identity/visibility_subjects/current_visibility/current_detail;两端CurrentAppVisibility待部署验收
AC-30action_sources/current_identity/visibility_subjects/current_visibility/current_detail;两端CurrentAppVisibility待部署验收
AC-31action_sources/current_identity/visibility_subjects/current_visibility/current_detail;两端CurrentAppVisibility待部署验收
AC-32action_sources/current_identity/visibility_subjects/current_visibility/current_detail;两端CurrentAppVisibility待部署验收
AC-33action_sources/current_identity/visibility_subjects/current_visibility/current_detail;两端CurrentAppVisibility待部署验收
AC-34action_sources/current_identity/visibility_subjects/current_visibility/current_detail;两端CurrentAppVisibility待部署验收
AC-35action_sources/current_identity/visibility_subjects/current_visibility/current_detail;两端CurrentAppVisibility待部署验收
AC-36action_sources/current_identity/visibility_subjects/current_visibility/current_detail;两端CurrentAppVisibility待部署验收
AC-37action_sources/current_identity/visibility_subjects/current_visibility/current_detail;两端CurrentAppVisibility待部署验收
AC-38action_sources/current_identity/visibility_subjects/current_visibility/current_detail;两端CurrentAppVisibility待部署验收
AC-39creation_policy/creation_entry;knowledge_create_atomicity/v2_filelib;Platform f115KnowledgeCreation待部署验收
AC-40creation_policy/creation_entry;knowledge_create_atomicity/v2_filelib;Platform f115KnowledgeCreation待部署验收
AC-41creation_policy/creation_entry;knowledge_create_atomicity/v2_filelib;Platform f115KnowledgeCreation待部署验收
AC-42creation_policy/creation_entry;knowledge_create_atomicity/v2_filelib;Platform f115KnowledgeCreation待部署验收
AC-43creation_policy/creation_entry;knowledge_create_atomicity/v2_filelib;Platform f115KnowledgeCreation待部署验收
AC-44creation_policy/creation_entry;knowledge_create_atomicity/v2_filelib;Platform f115KnowledgeCreation待部署验收
AC-45creation_policy/creation_entry;knowledge_create_atomicity/v2_filelib;Platform f115KnowledgeCreation待部署验收
AC-46creation_policy/creation_entry;knowledge_create_atomicity/v2_filelib;Platform f115KnowledgeCreation待部署验收
AC-47creation_policy/creation_entry;knowledge_create_atomicity/v2_filelib;Platform f115KnowledgeCreation待部署验收
AC-48creation_policy/creation_entry;knowledge_create_atomicity/v2_filelib;Platform f115KnowledgeCreation待部署验收
AC-49creation_policy/creation_entry;knowledge_create_atomicity/v2_filelib;Platform f115KnowledgeCreation待部署验收
AC-50creation_policy/creation_entry;knowledge_create_atomicity/v2_filelib;Platform f115KnowledgeCreation待部署验收
AC-51三语/架构守卫/拒绝场景与对应路径待部署验收
AC-52三语/架构守卫/拒绝场景与对应路径待部署验收
+
+ +

F115 特性代码审查(L2)

+

日期:2026-11-02。对照已确认spec52条、design和tasks;固定基线497256d289793ce016df07cf8ab38f32988faca4,含本地新增/未提交文件。方式:同一代理按sdd-code-review七维清单审查,非独立代理复审。

+

判定

+

代码静态审查:通过。SDD合入判定:NEEDS_FIX(真实验收门禁未完成)。

+

当前未发现未修复的本功能代码阻断项。真实部署E2E未授权/未执行,按AGENTS.md §6、SDD-Guide及已确认design §7.2不能标为验收完成。前端类型门禁亦受同一基线已有23/134条诊断限制,须在交付环境解决或依据用户明确的交付安排处理。没有因此擅自部署、推送或跳过门禁。

+

用户后续交付指令(2026-11-02)

+

用户获知上述结果后明确要求“提交推送 feat/3.0.0 分支”,因此按用户指令先完成提交、合入和推送。上方 NEEDS_FIX 保留为真实验收未完成的原审查结论;本次 Git 交付不将其改为 E2E 通过,不新增环境更新授权。

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
维度静态/本地检查结论
边界条件0/null、无效日期、无绑定/缺失/替换、分页不推进/预算、CURRENT/Catalog变化、人员歧义及目录失败均有拒绝或显式状态PASS
权限认证原任务/个人/管理阅读先行;摘要app/tenant绑定;真实owner actor;仅可信F048服务端端口读取,通用授权管理仍manage_permission;新建真实会话/资格写前复核PASS
并发安全观察绑定前后核对;来源前后stamp和身份解析后再读;旧前端响应序号丢弃;表单预期租户固定;新建复用原失败补偿PASS
信息泄漏无凭据落库/输出;摘要失败只给固定reason且不返回部分名单;跨租户在目录前拒绝;专用E2E清理精确限制PASS
测试覆盖本地后端/manager/两端组件及直接相关回归;真实5例仅收集,手动清单未执行;Jest启动限制已说明NEEDS_FIX:真实E2E待验
风格与分层SQL只在Repository;业务权限走application;不引入Docker/K8s SDK到backend;两SPA无状态混用;三语和生成产物、lint、无新增Ruff问题PASS_WITH_NOTES:旧基线类型检查未全绿
文档同步设计已记录真实接线、userset字段、来源双读、父层路径与错误码;release-contract/审批skill/tasks及验收记录同步;无AC倒改PASS
+

审查中修正的事项

+
    +
  • 平台resource及K8s辅助资源404不能当作执行体丢失;观察归属变化拒绝,不回退历史实例。
  • +
  • 应用删除态必须在app/tenant匹配后判断,避免通过删除态绕过绑定。
  • +
  • 已停止执行体不能保留失败探活的unhealthy相位。
  • +
  • 两端DTO统一为后端实际 userset_relation,包含subtree_member;提示明确授权即时生效、不影响审批。
  • +
  • 新增测试fixture类型错误已修正,新增导入与未使用变量均修正;现存类型诊断与基线逐项对照。
  • +
+

交付范围

+

未新增表、外部服务、权限动作或数据迁移。未修改其它审批场景resolver/outbox,未扩大复制及源码/预览资格。下一阶段只按 e2e-checklist.md 对专用对象作必要验收;无需全量回归。

\ No newline at end of file diff --git a/docs/reviews/vibe-runtime-review-discovery-2026-11-02.html b/docs/reviews/vibe-runtime-review-discovery-2026-11-02.html new file mode 100644 index 0000000000..f38b4e4d24 --- /dev/null +++ b/docs/reviews/vibe-runtime-review-discovery-2026-11-02.html @@ -0,0 +1,151 @@ +F115 已确认需求发现
需求发现 D1–D4 · 已确认

2026-11-02 · 基线 497256d28 · 需求发现与正式规格均已确认 · 尚未修改业务代码

F115 应用运行信息、审批可见范围与知识库创建:需求发现

+

日期:2026-11-02。阶段:用户已确认 D1–D4,进入正式规格;尚未修改业务代码。

+
    +
  • 基线:origin/feat/3.0.0@497256d28。
  • +
  • 分支:codex/vibe-runtime-review-20261102;独立工作树,主工作区保留。
  • +
  • 来源:用户本轮五项问题与六张截图;116 Docker 环境只读核对。
  • +
  • 交付约束:沿 SDD 推进,只做本功能定向验证;不迁移存量应用、不改角色绑定、不替用户修改应用代码、不运行全量回归。
  • +
+

1. 已核实的问题

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
项目当前事实与原因建议范围
1. 健康、重启、启动时间页面已有三个字段。116 的实例 API 返回 health=null、restart_count=0,缺少 started_at。同一容器实际 StartedAt=2026-10-30T10:33:15.363879485Z、重启 0 次,未配置 Docker HEALTHCHECK。v2 状态投影遗漏启动时间,并只读取 Docker Health,未用平台已有 HTTP 探活结果;K8s 投影也遗漏启动时间。补齐 Docker/K8s 当前应用执行体的启动时间;健康沿已有实际探活/Ready 事实呈现,不能把 Running 或缺失数据冒充健康。真实 0 次保留为 0,未知不可显示成 0。
2. 模型调用访问用户为空当前 116 Root、近 7 天查询返回 34 条记录,访问用户候选已有用户 ID 3。截图中的“应用自身”记录未保存自然人访问身份,现有候选只枚举日志中真实存在的 user/natural_person。未复现当前候选整体为空。保留真实身份过滤;存在用户记录时应能选、能筛选。只有应用自身调用时明确说明无访问用户记录;查询失败、搜索无匹配与无用户记录不能混为一谈。不从应用负责人、登录人或访问时间推测历史访问者,不回填旧日志。
3. 审批可见范围为空发布场景 Handler 只复制 payload.visibility_snapshot,发布管线未填充此字段;客户端始终读取历史 detail_snapshot,该字段为空就展示空文案。打开审批详情时读取应用当前有效的人员、部门和用户组授权摘要;只读展示,修改授权仍走既有权限流程并立即生效。保留原审批快照,不重写历史记录。
4. 首次提交空文案客户端把空数组统一翻译为“暂无可见范围信息”,没有区分“确认只有负责人”与“未知/查询失败”。仅当当前权限查询成功、确认没有负责人以外的有效普通访问授权时,显示“仅负责人可见”。首发后若已新增授权,仍展示最新对象。缺失数据、失败、应用已删除不能伪装为负责人可见。
5. 租管没有新建知识库按钮116 正式 GET /tenants/60/admins 确认 gzx01(849)为租户 60 管理员。角色菜单查询没有 create_knowledge。前端路由为租管补了 knowledge,但页面新建按钮仍只认 admin/create_knowledge;后端 create 也只认 create_knowledge 菜单,单改按钮会继续被拒绝。统一前后端的本租户知识库新建资格:当前租户管理员可新建文档/QA 知识库;普通用户继续走原创建菜单。租管不能借此选择其它租户。保留模型可用性、容量等既有检查。
+

已通过截图使用的 120:3002 入口再次对照:近 7 天、不限制应用时访问用户候选为 1;限定截图中的应用自身调用示例 c117490880544bf9b695019394488478 时候选为 0,两次均为业务码 200。先核对 120/116 鉴权公钥一致,再使用内存会话查询;未更新任一环境服务。

+

2. 已确认的业务边界

+

用户于 2026-11-02 明确回复“确认边界,进入正式规格”,确认以下 D1–D4;这不代替后续 spec、design 的单独确认。

+

D1. 运行信息表示真实当前执行体

+
    +
  • Docker 使用精确绑定的当前容器;K8s 使用精确绑定的当前 Pod/应用容器,不混入初始化/维护容器。
  • +
  • 健康优先反映已有平台探活/Ready 结果,明确区分正常、异常、启动中、未知;本次不新增独立监控系统或定时探活体系。
  • +
  • 重启次数表示当前应用执行体的实际重启计数,不把版本升级、替换实例和应用历史发布次数累加为重启次数。
  • +
  • 不重建现有应用来补显示信息;时间按现有统一时间展示工具转换。
  • +
+

D2. 模型调用保留真实身份

+
    +
  • 候选来自当前授权租户/应用/时间范围内的真实用户调用记录;过滤使用相同范围。
  • +
  • 应用以自身身份调用模型时,记录不代表某个自然人。需要按人查询的新调用,应由应用按现有 SDK/OBO 契约转发访问者身份,本次不替已有应用改码。
  • +
  • 不新增虚构人员候选,不把“用户不存在于这些记录”解释为全租户人员都可筛选;无记录提示三语交付。
  • +
+

D3. 审批只读展示当前授权

+
    +
  • 这是对 F055 AC-24“提交时可见范围快照”的明确修订:代码、声明、版本、审批决策仍冻结;仅可见范围区改为查询时的最新授权,并标注“当前可见范围,授权变更即时生效,不影响审批”。
  • +
  • 按现有 F048 use 生效动作识别访问授权,涵盖直接及现有继承语义;不能按模型名字认定权限。
  • +
  • 展示人员、部门、用户组名称与类型;部门/组不展开成全部成员。服务账号不是普通浏览者,不作为人员行列出。
  • +
  • 不枚举所有因管理员身份而隐式可见的账号;这些身份继续按既有平台规则生效。
  • +
  • 当前审批阅读资格仍须校验。跨租户、失去资格、权限依赖不可用时不得泄露当前授权目录;不能借当前摘要获得应用 edit/manage_permission 或源码访问权。
  • +
  • 进行中和已进入终态的、仍有合法阅读资格的审批详情,均显示当前授权;历史快照不改写。已删除应用与不可确认权限分别提示原因。
  • +
  • 人员迁租户、停用、授权撤销与模型动作变化不能被误写成“仍可访问”;不迁移应用或授权,不修补存量历史数据。
  • +
+

D4. 知识库创建资格

+
    +
  • 当前有效的租户管理员默认可在自己所属租户新建文档/QA 知识库,前端入口和后端资格一致;不信任前端传来的管理员标记。
  • +
  • 新资源仍归当前创建人及其所属租户,不用页面管理视角改变归属。超管与普通用户沿现有创建规则。
  • +
  • 不扩大一般部门管理员的权限,不新增角色或批量补写用户授权。
  • +
  • 本次只修新建,复制仍保留原创建菜单和源资源读取资格;页面共享条件需拆清,不能顺带放开复制。
  • +
+

3. 证据锚点

+

以下源码均已在固定基线 497256d28 核对,116 运行层仍使用 10 月 30 日的本机 F114 镜像;后端和前端 release 镜像已有后续更新。截图中“整个平台”不作为当前最新代码仍保留该选项的证据。

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
链路源码位置
v2 实例状态投影src/runtime-manager/runtime_manager/api/platform_observe.py::status
Docker 实际 HTTP 探活src/runtime-manager/runtime_manager/executors/docker.py::observe
K8s Ready 与应用容器重启计数src/runtime-manager/runtime_manager/executors/kubernetes.py::_observed
后端实例字段白名单src/backend/bisheng/app_runtime/domain/services/app_query_service.py::get_instance
发布页已有显示字段src/frontend/platform/src/pages/BuildPage/hostedApp/tabs/PublishTab.tsx
审计候选/过滤src/backend/bisheng/app_governance/domain/repositories/runtime_audit_repository.py::candidates / statement
审批快照占位与客户端读取app_publish/.../app_publish_scenario_handler.py::build_detail、client/.../approval/AppPublishDetailPanel.tsx
租管前端路由补全src/frontend/platform/src/routes/index.tsx::CHILD_ADMIN_ROUTE_PERMISSIONS
知识库按钮与后端创建菜单KnowledgePage/KnowledgeFile.tsx、KnowledgeQa.tsx、knowledge/api/endpoints/knowledge.py::create_knowledge
+

4. 定向验证计划

+
    +
  1. 状态投影:Docker 有/无原生健康检查、探活成功/失败/未知、重启为 0/正数/缺失;启动时间、执行体身份匹配;K8s 应用容器与初始化容器分离。
  2. +
  3. 模型记录:自然人/OBO 与应用自身配对;候选和列表过滤一致;租户、应用、时间与无候选/搜索空/加载失败边界;不篡改历史记录。
  4. +
  5. 审批范围:负责人可见、多类授权、授权变更后刷新、进行中/终态、use 与其它动作区分、继承、人员失效、跨租户与无权拒绝;当前摘要失败不影响不可变审批数据。
  6. +
  7. 新建知识库:无 create_knowledge 菜单的本租管允许;无菜单普通用户拒绝、有菜单普通用户允许;跨租户拒绝、文档与 QA 一致、复制资格不扩展。
  8. +
  9. 仅运行上述相关测试、变更文件静态检查与必要页面验证;真实部署/写入验收另行明确,不自动更新 116 或 120 服务。
  10. +
+

5. 下一阶段与暂停依据

+

依据仓库 AGENTS.md §6 与 SDD-Guide §1/§3,第 3 项修改已经确认的审批快照契约,第 5 项涉及创建菜单资格的前后端一致性,采用完整 SDD。D1–D4 已获用户确认;当前进入正式 spec 编写和评审,经用户单独确认 spec 后进入设计。设计仍须单独确认,之后才能拆任务与编码。

\ No newline at end of file diff --git a/docs/reviews/vibe-runtime-review-spec-review-2026-11-02.html b/docs/reviews/vibe-runtime-review-spec-review-2026-11-02.html new file mode 100644 index 0000000000..68bea5cbe2 --- /dev/null +++ b/docs/reviews/vibe-runtime-review-spec-review-2026-11-02.html @@ -0,0 +1,365 @@ +F115 已确认规格与自审记录
正式规格 · 已确认

2026-11-02 · 基线 497256d28 · 需求发现与正式规格均已确认 · 尚未修改业务代码

52 条验收标准10 项清单自审通过正式规格已获用户确认设计草案待确认

F115:应用运行信息、审批当前可见范围与知识库创建

+
+

本文档定位:纯 What。 只定义需求、验收标准与边界;实现选择、接口、数据模型及文件变更在设计阶段确定。

+
+

状态:用户于 2026-11-02 明确确认正式规格,进入设计;需求发现 D1–D4 同日已确认,尚未进行业务编码。

+

优先级:P0(权限边界与错误信息一致性)。
+所属版本:v3.0.0;发版线 feat/3.0.0。
+编写基线:origin/feat/3.0.0@497256d289793ce016df07cf8ab38f32988faca4。此基线及只读排查记录不代表 F115 已实现或环境已更新。
+关联 PRD:本轮已确认需求发现 D1–D4(直接需求来源);应用工场 PRD-1 RT-01、RT-03、GOV-01、GOV-04(既有产品背景)。
+依赖:F048 权限动作;F054 应用与运行状态;F055 发布审批;F056 运行审计查询;F059/F086 运行形态;F113 身份与租户资格;F114 权限映射及具体租户视角。审批引擎、知识库创建与组织身份继续由既有领域负责。

+

范围

+

本次纳入:

+
    +
  • 发布页显示当前执行体的真实健康状态、实际重启次数与启动时间,区分未知和真实零值。
  • +
  • 模型调用的访问用户候选与筛选保持真实、范围一致,明确无用户记录、搜索无匹配、加载与失败状态。
  • +
  • 应用发布审批只读展示当前有效的人员、部门、用户组访问授权;能确认时显示“仅负责人可见”。
  • +
  • 当前有效的租户管理员可在所属租户新建文档和 QA 知识库,入口与实际创建资格一致。
  • +
+

本次明确排除:

+
    +
  • 不建设新的监控系统,不扩大探活体系,不为补显示而重启或重建现有应用。
  • +
  • 不猜测、回填模型调用的历史访问身份,不替现有应用改码转发访问者身份。
  • +
  • 不修改审批决定、版本内容或历史快照,不把可见范围变更变成发布审批事项。
  • +
  • 不迁移、移交应用或知识库,不批量补授权,不更改已有角色、部门或用户组绑定。
  • +
  • 不新增权限动作,不扩大普通用户、一般部门管理员或知识库复制资格。
  • +
  • 不扩展源码审读、审批预览、模型调用或其它平台能力的授权。
  • +
+

1. 用户故事

+
    +
  1. 作为应用管理者,我希望看到当前实例实际的健康、重启和启动情况,以便判断运行是否正常,并识别信息暂不可用。
  2. +
  3. 作为有审计资格的管理员,我希望按真实访问用户筛选模型调用,并知道候选为空的原因,以便准确定位调用而不误判应用自身调用。
  4. +
  5. 作为有权阅读应用发布审批的人,我希望看到应用当前已授权的人员、部门和用户组,以便理解当前访问范围,且不把它误认为冻结的发布内容。
  6. +
  7. 作为首次发布的负责人或审批阅读者,我希望在确实只有负责人可见时看到明确说明,以便知道是否还需要设置可见范围。
  8. +
  9. 作为租户管理员,我希望无需额外创建菜单授权即可在所属租户新建文档或 QA 知识库,以便履行本租户管理职责。
  10. +
+

术语

+
    +
  • 当前可见范围:当前有效的普通访问授权对象,按既有 use 动作及身份、租户规则判定;不等同于应用此刻已经上线、可以建立运行连接。草稿、待审批或已下线本身不应清空其有效授权范围。
  • +
  • 负责人:应用当前记录的资源归属自然人;是否仍具有效访问资格应另行确认,不因名字仍在审批单上而推定有效。
  • +
  • 租户管理员:具备当前有效租户管理资格的自然人;不同于只有部门管理资格的人员。
  • +
+

2. 验收标准

+

2.1 当前执行体的运行信息(D1)

+
    +
  • AC-01 — WHEN 有权查看运行状态的用户打开或刷新应用发布页,THE SYSTEM SHALL 仅以该应用当前精确绑定的执行体展示运行信息,不混入其它应用、旧实例、审批预览或维护执行体的信息。
  • +
  • AC-02 — WHEN 当前执行体已有可用的应用探活或就绪结果,THE SYSTEM SHALL 按真实结果展示健康正常或异常;Docker 未配置原生健康检查不能成为忽略已有平台应用探活结果的理由。
  • +
  • AC-03 — WHILE 当前执行体仍在启动且尚无已完成的健康判定,THE SYSTEM SHALL 表达启动中;IF 无法确认当前健康,THEN THE SYSTEM SHALL 表达未知,不以“进程运行中”或缺失结果推断健康正常。
  • +
  • AC-04 — IF 运行信息查询失败或观测依赖不可用,THEN THE SYSTEM SHALL 明确表示相关信息暂不可确认并允许刷新;仍可独立确认的信息保持可读,不把查询故障伪装成应用健康、零次重启或不存在。
  • +
  • AC-05 — WHEN 显示重启次数,THE SYSTEM SHALL 使用当前执行体中应用容器的真实重启计数,不累加应用历史发布次数、版本升级次数或被替换执行体的计数,也不混入初始化或维护容器的计数。
  • +
  • AC-06 — WHEN 可确认重启次数为零或正整数,THE SYSTEM SHALL 如实显示该值;IF 无法取得或确认计数,THEN THE SYSTEM SHALL 显示未知,不以零代替。
  • +
  • AC-07 — WHEN 可取得当前应用容器的实际启动时间,THE SYSTEM SHALL 展示该时间,不用审批时间、发布时间、Pod 创建时间或初始化容器的启动时间代替。
  • +
  • AC-08 — WHEN 展示有效启动时间,THE SYSTEM SHALL 遵循平台已有的统一时区展示口径;IF 时间缺失、无效或未实际启动,THEN THE SYSTEM SHALL 显示未知或尚未启动,不显示虚构日期。
  • +
  • AC-09 — WHEN 应用停止、执行体不存在或已被替换,THE SYSTEM SHALL 区分这些状态,且不得把已失效执行体的健康、重启或启动信息继续标为当前实例信息;刷新后应反映当前绑定。
  • +
  • AC-10 — WHERE 应用运行于 Docker 或 K8s,THE SYSTEM SHALL 对上述状态采用一致的产品含义,且读取或刷新运行信息不得触发发布、重启、重建或资产变更。
  • +
+

2.2 模型调用访问用户候选与过滤(D2)

+
    +
  • AC-11 — WHEN 查询模型调用的访问用户候选,THE SYSTEM SHALL 将候选限定在调用者当前有权查询的具体租户、应用、时间及其它已选筛选条件内,与相应记录查询的范围一致。
  • +
  • AC-12 — WHEN 记录中存在经平台验证的自然人执行身份,THE SYSTEM SHALL 允许该真实身份成为访问用户候选;不得依据应用负责人、当前登录人、调用时间接近或应用标识推测访问用户。
  • +
  • AC-13 — WHEN 模型调用仅以应用自身或服务账号自身身份执行,THE SYSTEM SHALL 不把该调用者冒充为自然人访问用户;应用缺少访问者身份不等于数据查询失败。
  • +
  • AC-14 — WHEN 当前查询范围存在自然人访问记录,即使同时存在应用自身调用,THE SYSTEM SHALL 提供对应自然人候选,并允许选择和按该用户筛选;不得因混合身份记录而使整个候选列表为空。
  • +
  • AC-15 — WHEN 展示访问用户候选,THE SYSTEM SHALL 保留能够对应记录的真实身份标识;不得混入无相关调用记录的全租户人员,或用负责人等其它账号替换已不存在或不可确认的历史身份。
  • +
  • AC-16 — WHEN 选择访问用户并查询记录,THE SYSTEM SHALL 仅返回该真实身份与其它已选条件同时匹配的模型记录,不能将同一应用的其它用户或应用自身调用归给该用户。
  • +
  • AC-17 — IF 查询成功且当前范围没有自然人访问记录,THEN THE SYSTEM SHALL 明确提示当前范围没有访问用户记录,并说明应用自身调用不代表自然人访问,不仅显示无含义的空白。
  • +
  • AC-18 — IF 访问用户搜索成功但没有匹配候选,THEN THE SYSTEM SHALL 提示没有匹配用户,区别于整个范围没有自然人记录;清除搜索后可恢复当前范围的真实候选。
  • +
  • AC-19 — WHILE 候选正在加载,THE SYSTEM SHALL 表达加载状态;IF 加载失败,THEN THE SYSTEM SHALL 表达失败并允许重试,不将失败展示成“没有访问用户记录”或“没有匹配用户”。
  • +
  • AC-20 — WHEN 具体租户、应用、时间或相关查询条件改变,THE SYSTEM SHALL 使候选及筛选结果对应新范围,不保留或泄露旧范围的候选与结果;不再适用的已选身份不得导致界面悄悄查询旧范围。
  • +
  • AC-21 — WHEN 提供上述查询与提示,THE SYSTEM SHALL 保留已有可信访问者与 SDK/OBO 身份规则,不回填或改写历史记录;需要按人记录的新调用仍须带有依既有规则验证的访问者身份。
  • +
+

2.3 审批中的当前可见范围(D3)

+
    +
  • AC-22 — WHEN 有合法阅读资格的用户打开或刷新应用发布审批详情,THE SYSTEM SHALL 展示查询时应用当前有效的可见范围,不继续把提交时的空占位或旧授权快照当作当前范围。
  • +
  • AC-23 — WHEN 识别当前可见范围,THE SYSTEM SHALL 以现有 F048 use 生效动作和既有身份、租户条件为准,涵盖直接授权及现有继承语义,包括归属人的有效所有者访问资格;草稿、待审批或已下线本身不应清空有效授权范围,也不表示应用已经可以运行访问。
  • +
  • AC-24 — WHEN 存在有效的普通访问授权对象,THE SYSTEM SHALL 按人员、部门、用户组分别展示对象名称及类型,能区分同名不同类型对象;部门和用户组不展开成全部成员。
  • +
  • AC-25 — WHEN 展示普通访问授权对象,THE SYSTEM SHALL 排除服务账号,并说明管理员仍按平台既有规则访问,不把所有因管理员身份而隐式可见的账号逐一列作普通人员授权。
  • +
  • AC-26 — IF 某对象只有其它管理动作而没有生效的 use,THEN THE SYSTEM SHALL 不将其认作当前访问授权对象;不能单凭“所有者”“管理者”等权限模型名称判定可见。
  • +
  • AC-27 — WHEN 授权被撤销、相关人员停用或迁离租户,或权限模型动作发生变化,THE SYSTEM SHALL 在后续打开或刷新时按现有有效资格反映变化,不把已失效对象继续表达为可以访问;部门或用户组的展示不承诺其每一名成员都已获得访问资格。
  • +
  • AC-28 — IF 当前范围查询成功且完整、负责人当前具备有效访问资格,且没有负责人以外的有效普通访问授权对象,THEN THE SYSTEM SHALL 明确显示“仅负责人可见”;此文案不否定 AC-25 所述管理员隐式访问规则。IF 完整查询确认负责人也没有有效访问资格且无其它有效普通访问授权对象,THEN THE SYSTEM SHALL 表达当前没有有效普通访问对象,而非“仅负责人可见”。
  • +
  • AC-29 — WHEN 首次发布的审批详情被打开,THE SYSTEM SHALL 依据当前有效范围决定显示“仅负责人可见”或具体授权对象;首次发布本身、历史快照为空、负责人失效或当前查询失败均不能单独作为“仅负责人可见”的依据。
  • +
  • AC-30 — WHEN 显示该区域,THE SYSTEM SHALL 明确标注“当前可见范围,授权变更即时生效,不影响审批”的含义;变更可见范围仍走既有授权流程,不创建新发布审批、不重置现有审批进度,也不要求重新提交版本。
  • +
  • AC-31 — WHEN 读取当前可见范围,THE SYSTEM SHALL 保持原审批快照和历史记录不变;代码、版本、能力声明、资源档位、发布说明及审批决定继续遵守各自原有冻结规则,不能因刷新可见范围被替换成应用最新内容。
  • +
  • AC-32 — WHERE 应用发布审批处于进行中或已经进入通过、驳回、撤回、取消等终态,THE SYSTEM SHALL 对仍具合法阅读资格的用户提供相同含义的当前可见范围;进入终态本身不新增阅读资格。
  • +
  • AC-33 — WHEN 用户具备该次审批的合法阅读资格,THE SYSTEM SHALL 允许其只读查看该审批所对应应用的当前人员、部门、用户组访问授权摘要,不额外要求长期应用 manage_permission;该例外只用于当前审批展示,不成为任意查询应用授权目录的资格。
  • +
  • AC-34 — WHEN 用户通过 AC-33 查看当前摘要,THE SYSTEM SHALL 不因此授予授权增删、权限模型选择、应用管理、源码审读或预览资格;审批之外的完整授权主体查询及授权变更仍须遵守 F114 的 manage_permission 规则,原有审批审读与预览资格另行判定。
  • +
  • AC-35 — IF 用户没有该审批的阅读资格、已失去资格,或请求违反现有租户隔离,THEN THE SYSTEM SHALL 拒绝读取当前可见范围,且不泄露授权对象信息;知道审批或应用标识不能代替资格。
  • +
  • AC-36 — IF 对应应用已删除,THEN THE SYSTEM SHALL 明确表示无法读取当前可见范围的原因,不显示“仅负责人可见”,也不恢复应用、授权或审批;其它依法可读的冻结审批内容保持可读。
  • +
  • AC-37 — IF 不能完整确认当前授权或对象资格,THEN THE SYSTEM SHALL 明确表示当前可见范围暂不可确认,并允许重新查询;不得把失败、部分结果或缺失信息当作完整名单、无人可见或“仅负责人可见”,其它依法可读的冻结审批内容仍保持可读。
  • +
  • AC-38 — WHERE 既有“我的审批”“我的申请”或审批管理详情允许阅读该应用发布审批,THE SYSTEM SHALL 保持上述当前范围含义一致;本次不新增审批管理入口或异常处理资格。
  • +
+

2.4 所属租户的知识库新建资格(D4)

+
    +
  • AC-39 — WHEN 当前有效的租户管理员进入文档知识库页面,THE SYSTEM SHALL 提供新建入口,并允许其在自己所属租户创建文档知识库,不要求另有创建知识库菜单授权。
  • +
  • AC-40 — WHEN 当前有效的租户管理员进入 QA 知识库页面或提交新建,THE SYSTEM SHALL 提供与文档知识库一致的本租户新建资格,不因知识库类型不同而缺失入口或被创建菜单拒绝。
  • +
  • AC-41 — WHEN 判断是否可以新建知识库,THE SYSTEM SHALL 使页面入口与实际创建资格一致,并以服务端确认的当前身份和所属租户判定;仅修改界面、伪造管理员标记或绕过页面直接请求均不能获得额外资格。
  • +
  • AC-42 — WHEN 按上述资格创建知识库,THE SYSTEM SHALL 保持资源归属为实际创建自然人及其所属租户,不把页面管理视角、选中的其它租户或服务账号改写为创建人和资源租户。
  • +
  • AC-43 — IF 租户管理员试图在所属租户以外创建知识库,THEN THE SYSTEM SHALL 拒绝,不允许通过篡改租户信息或沿用其它租户管理页面完成创建。
  • +
  • AC-44 — IF 打开新建表单后,人员被停用、租户管理资格被撤销或所属租户变化,THEN THE SYSTEM SHALL 在实际提交时重新按当前有效资格判定;不再有创建资格或表单目标不再属于当前所属租户时拒绝,不能写入旧租户或静默改写到其它租户。
  • +
  • AC-45 — WHEN 无创建菜单且不具租户管理员或其它既有创建资格的普通用户尝试新建,THE SYSTEM SHALL 保持拒绝,不因本次修复取得创建资格。
  • +
  • AC-46 — WHEN 普通用户已有有效创建菜单授权,THE SYSTEM SHALL 保留其原有新建资格及其它既有校验,不要求该用户同时成为租户管理员。
  • +
  • AC-47 — WHEN 平台超管新建知识库,THE SYSTEM SHALL 保持其既有创建资格与归属规则,不把管理视角切换改为创建到所选租户的能力。
  • +
  • AC-48 — WHEN 用户只有一般部门管理员身份,THE SYSTEM SHALL 不因该身份获得本次新增的租户管理员创建资格;其其它合法创建途径不受影响。
  • +
  • AC-49 — WHEN 用户复制知识库,THE SYSTEM SHALL 继续要求原有创建菜单资格与源资源的读取资格;本次本租户新建例外不自动放开复制、源资源读取或其它资源动作。
  • +
  • AC-50 — WHEN 租户管理员按新增资格提交新建,THE SYSTEM SHALL 继续遵守已有模型可用性、容量、名称、租户状态等创建条件;不得通过新增角色、补写个人菜单或批量授权来改变已有账号配置。
  • +
+

2.5 共同约束

+
    +
  • AC-51 — WHEN 展示本功能新增或修改的状态、提示及范围说明,THE SYSTEM SHALL 在简体中文、英文和日文中提供含义一致的文案,保留真实零值、未知、无记录、查询失败及“仅负责人可见”的区别。
  • +
  • AC-52 — WHEN 使用上述运行信息、审计查询、审批摘要或新建资格,THE SYSTEM SHALL 保持现有身份有效性、具体租户视角及资源权限边界,不迁移存量资源、改写历史记录或自动补授权;本规格的明确例外仅为 AC-33 的审批只读摘要与 AC-39、AC-40 的本租户知识库新建。
  • +
+

3. 边界情况

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
场景期望的可观测结果验收关联
Docker 没有原生健康检查,但已有平台探活事实使用真实应用探活结果;没有可确认结果才表达未知AC-02、AC-03
进程运行但探活失败、执行体正在启动、观测依赖失败分别表达异常、启动中、暂不可确认,不能统称健康AC-02–AC-04
重启计数为零、缺失;应用容器与初始化容器时间不同零与未知不同;只显示当前应用容器事实AC-05–AC-08
停止、删除执行体、版本替换后刷新不把旧执行体信息当作当前值;读取没有生命周期副作用AC-09、AC-10
历史记录只有应用自身身份,或与自然人记录混合前者明确无访问用户;后者保留真实用户候选AC-12–AC-17
搜索无匹配、加载失败、具体租户切换三者不能混为无记录;重试与新范围不泄露旧信息AC-18–AC-20
首发审批之后授权增加、减少,或人员迁租户/停用刷新显示当前有效对象;不回写审批快照AC-22–AC-32
审批阅读者没有长期管理授权只读当前摘要;不因此获得授权管理、源码或预览权限AC-33、AC-34
已无任何有效普通访问对象,且负责人也失效不能显示“仅负责人可见”;完整查询可表达当前无有效普通访问对象,并与依赖失败区分AC-23、AC-27–AC-29、AC-37
应用删除、权限或组织信息不能完整确认明确原因,不显示误导性的负责人可见或完整名单;保留合法冻结内容AC-35–AC-37
租管无创建菜单,或表单打开后资格变化前者可在所属租户新建;后者重新校验并按当前资格拒绝不再合法的提交AC-39–AC-44
复制知识库、一般部门管理员、普通有/无菜单用户本次不扩大复制或部门管理资格,普通用户沿原创建规则AC-45–AC-50
+

4. 与既有规格的关系

+

本节登记需求增量,不将旧规格原文或旧验收证据改写为本功能已交付。

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
既有合同本规格关系
PRD-1 RT-03 审批人侧“可见范围:提交时快照”依本轮已确认 D3,按本规格当前授权展示口径修订;仅此展示项发生变化,不把发布其它内容改为动态
F054 运行状态;F059/F086 双形态AC-01–AC-10 补齐当前事实与未知语义;不改变运行控制协议或生命周期权限
F056 审计查询;F051 调用身份事实AC-11–AC-21 明确真实身份与空态,不新增自然人身份来源、不改历史账本
F055 AC-24 的可见范围快照部分由 AC-22–AC-32、AC-36、AC-37 修订为查询时的当前授权展示;其余冻结审读与审批行为保持
F114 AC-33 的完整授权主体查看限制追加 AC-33 的限定审批只读例外;其它授权目录查询、授权编辑仍按 AC-34 和原 manage_permission 合同
F114 审批任务/管理资格及 F113 租户边界AC-32–AC-38、AC-52 保留原资格;当前摘要不产生新的审批、管理或跨租户能力
知识库既有新建菜单资格AC-39、AC-40 追加当前租户管理员的所属租户新建资格;AC-42–AC-50 保留归属、其它校验及复制规则
版本领域归属及 INV不新增独立领域对象或权限动作,不修改不变量;只协调原有领域需求增量
+

5. 设计与实施

+

正式规格已获用户单独确认,当前进入设计;任务拆解与业务编码尚未开始。设计再单独确认后进入任务与开发。接口、错误码、数据流、实现文件及定向验证步骤在相应阶段定义。

+

验证边界沿已确认发现:只做本功能必要的定向验证,不跑全量测试;真实环境写入验收和服务更新不由本次规格确认自动授权。

+

相关文档

+ +
+ +

F115 正式规格自审记录

+

日期:2026-11-02。对象:spec.md,AC-01 至 AC-52,共 52 条。审查方式:同一代理按 sdd-review 的 spec 清单自审,非独立代理复审。

+

阶段更新:用户于 2026-11-02 回复“确认规格,进入设计”,正式规格已确认;下文保留当次自审与当时暂停点。

+

结论

+

LGTM

+

此结论仅表示当前规格通过静态自审,仍须用户单独确认正式规格;不表示设计已确认、业务代码已完成或真实环境已通过验证。

+

审查依据

+
    +
  • 已获用户确认的 discovery.md D1–D4 与五项原始问题。
  • +
  • 应用工场 PRD-1 的 RT-01、RT-03、GOV-01、GOV-04;本轮 D3 是用户明确采纳的可见范围展示合同修订,D4 是明确采纳的创建资格增量。
  • +
  • features/_templates/spec.md、docs/SDD-Guide.md、docs/constitution.md、v3.0.0 release-contract.md。
  • +
  • F055 AC-24、F114 AC-33 及既有审批/租户资格;后端领域与权限架构说明。
  • +
  • .claude/skills/sdd-review/references/spec-checklist.md 的实际 10 项检查。技能入口中的“14 项”及“spec 合并设计”说明与现行清单、模板和 SDD Guide 不一致,本次按现行清单与纯 What 口径执行,没有冒记额外检查。
  • +
+

清单结果

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
#检查项结果核对证据
1功能与场景覆盖PASSD1→AC-01–AC-10;D2→AC-11–AC-21;D3→AC-22–AC-38;D4→AC-39–AC-50;共同约束→AC-51、AC-52,五项问题均有对应
2边界与错误场景PASS运行未知/失败/替换、真实零值、混合调用身份、搜索/加载失败、失效对象、终态审批、删除与不完整授权、新建资格变化及复制边界均有可观测结果
3明确排除项PASS不新建监控体系、不猜测身份、不改历史快照、不改已有应用源码、不迁移资源、不批量补授权、不扩展复制与其它角色资格
4唯一且可追溯 ACPASS52 个定义,连续且唯一为 AC-01–AC-52;范围表、边界表和合同关联均引用本规格已定义编号
5可测试性PASS零值/未知、合法范围、真实身份、只读摘要、可见对象、创建拒绝均有可观察条件与结果;无“响应快”等不可测要求
6EARS 句型PASS每条 AC 使用 WHEN / WHILE / IF / WHERE 与 THE SYSTEM SHALL;条件变化、异常拒绝与正常结果分明
7错误码边界PASS规格只要求可观测错误含义,未分配错误码或维护实现错误码表
8领域归属PASS只协调既有运行信息、审计、发布场景及知识库创建资格;版本契约表与增量记录同步登记 F115,没有取得权限/审批/组织对象所有权
9不变量与架构铁律PASS保持可信身份、租户隔离、双形态一致、发布必审、资产持久;不要求直接访问权限引擎或编排器。F055/F114 两处既有合同修订已明示,不伪装为原规则不变
10纯 WhatPASS不含接口形态、数据模型、分层接线、实现文件清单或性能指标;仅定义用户结果与权限边界,设计尚未开始
+

本轮自审补齐

+
    +
  1. 显式登记 F114 AC-33 的限定审批摘要例外,防止将“审批人可读”误扩展为通用授权目录读取资格。
  2. +
  3. 明确“当前授权范围”与“此刻运行可达”不同:草稿、待审批和下线本身不清空已生效授权,否则首发审批永远无法准确表达范围。
  4. +
  5. 明确负责人已失效且无其它有效普通授权时的空态;完整查询失败或部分结果不能宣称“仅负责人可见”。
  6. +
+

以上是对已确认 D3 边界的明晰化,不新增资源移交、授权写入或发布行为。

+

验证范围与暂停点

+
    +
  • 仅核对 Markdown 结构、AC 唯一与引用、链接、HTML 导航及源文一致性;本轮没有业务代码变更,不运行业务测试或全量回归。
  • +
  • D1–D4 已于 2026-11-02 获用户确认;正式 spec 尚待确认,依据 AGENTS.md §6、SDD-Guide §3 停在 spec 确认点。
  • +
  • 用户确认后进入 design,并在设计中说明真实身份候选、当前授权查询边界、运行事实及知识库创建资格的具体实现与定向验收。
  • +
\ No newline at end of file diff --git a/features/v3.0.0/115-vibe-runtime-review-fixes/code-review.md b/features/v3.0.0/115-vibe-runtime-review-fixes/code-review.md new file mode 100644 index 0000000000..489f36f701 --- /dev/null +++ b/features/v3.0.0/115-vibe-runtime-review-fixes/code-review.md @@ -0,0 +1,35 @@ +# F115 特性代码审查(L2) + +日期:2026-11-02。对照已确认spec52条、design和tasks;固定基线497256d289793ce016df07cf8ab38f32988faca4,含本地新增/未提交文件。方式:同一代理按sdd-code-review七维清单审查,非独立代理复审。 + +## 判定 + +**代码静态审查:通过。SDD合入判定:NEEDS_FIX(真实验收门禁未完成)**。 + +当前未发现未修复的本功能代码阻断项。真实部署E2E未授权/未执行,按AGENTS.md §6、SDD-Guide及已确认design §7.2不能标为验收完成。前端类型门禁亦受同一基线已有23/134条诊断限制,须在交付环境解决或依据用户明确的交付安排处理。没有因此擅自部署、推送或跳过门禁。 + +### 用户后续交付指令(2026-11-02) + +用户获知上述结果后明确要求“提交推送 feat/3.0.0 分支”,因此按用户指令先完成提交、合入和推送。上方 NEEDS_FIX 保留为真实验收未完成的原审查结论;本次 Git 交付不将其改为 E2E 通过,不新增环境更新授权。 + +| 维度 | 静态/本地检查 | 结论 | +|---|---|---| +| 边界条件 | 0/null、无效日期、无绑定/缺失/替换、分页不推进/预算、CURRENT/Catalog变化、人员歧义及目录失败均有拒绝或显式状态 | PASS | +| 权限认证 | 原任务/个人/管理阅读先行;摘要app/tenant绑定;真实owner actor;仅可信F048服务端端口读取,通用授权管理仍manage_permission;新建真实会话/资格写前复核 | PASS | +| 并发安全 | 观察绑定前后核对;来源前后stamp和身份解析后再读;旧前端响应序号丢弃;表单预期租户固定;新建复用原失败补偿 | PASS | +| 信息泄漏 | 无凭据落库/输出;摘要失败只给固定reason且不返回部分名单;跨租户在目录前拒绝;专用E2E清理精确限制 | PASS | +| 测试覆盖 | 本地后端/manager/两端组件及直接相关回归;真实5例仅收集,手动清单未执行;Jest启动限制已说明 | NEEDS_FIX:真实E2E待验 | +| 风格与分层 | SQL只在Repository;业务权限走application;不引入Docker/K8s SDK到backend;两SPA无状态混用;三语和生成产物、lint、无新增Ruff问题 | PASS_WITH_NOTES:旧基线类型检查未全绿 | +| 文档同步 | 设计已记录真实接线、userset字段、来源双读、父层路径与错误码;release-contract/审批skill/tasks及验收记录同步;无AC倒改 | PASS | + +## 审查中修正的事项 + +- 平台resource及K8s辅助资源404不能当作执行体丢失;观察归属变化拒绝,不回退历史实例。 +- 应用删除态必须在app/tenant匹配后判断,避免通过删除态绕过绑定。 +- 已停止执行体不能保留失败探活的unhealthy相位。 +- 两端DTO统一为后端实际 `userset_relation`,包含subtree_member;提示明确授权即时生效、不影响审批。 +- 新增测试fixture类型错误已修正,新增导入与未使用变量均修正;现存类型诊断与基线逐项对照。 + +## 交付范围 + +未新增表、外部服务、权限动作或数据迁移。未修改其它审批场景resolver/outbox,未扩大复制及源码/预览资格。下一阶段只按 [e2e-checklist.md](./e2e-checklist.md) 对专用对象作必要验收;无需全量回归。 diff --git a/features/v3.0.0/115-vibe-runtime-review-fixes/design-review.md b/features/v3.0.0/115-vibe-runtime-review-fixes/design-review.md new file mode 100644 index 0000000000..49ef7883a0 --- /dev/null +++ b/features/v3.0.0/115-vibe-runtime-review-fixes/design-review.md @@ -0,0 +1,73 @@ +# F115 设计自审记录 + +阶段更新:用户已于2026-11-02单独确认本设计,随后已实施;下文保留设计确认前的自审结论,开发与部署状态以verification.md为准。 + +日期:2026-11-02。对象:`design.md`。方式:同一代理按 `sdd-review/references/design-checklist.md` 的 24 项清单自审,非独立代理复审。 + +## 结论 + +LGTM + +设计可提交用户评审;本结论不代替用户单独确认设计,也不表示业务代码、镜像或真实环境已验证。spec 的 52 条 AC 已获用户确认,本轮没有更改其验收要求。 + +## 本轮自审已修正的问题 + +| 项 | 观察到的问题 | 已落到设计 | +| --- | --- | --- | +| R1 | 把原运行任务的唯一 UserTenant 身份前置直接用到 Web 新建,会破坏部分旧单租户创建行为 | D6、§4.6:同一 user 应用模块提供新建薄入口;关闭多租户固定 1,不要求补成员行;原 runtime 严格合同不变 | +| R2 | 用请求上下文的 actor resolver 查询负责人,可能取到审批阅读者身份 | §4.4 C、§5:负责人使用自身真实事实,明确不得借用请求 actor 缓存或强设超管 | +| R3 | 单纯采用 grant 来源会遗漏失效人员、投影中间态或分页截断,错误产生 owner-only | §4.4 B–C:CURRENT/版本/Catalog 前后复核、目录三态、预算失败显式 unavailable;非支持来源不能静默忽略 | +| R4 | backend 先返回 null 时旧 UI 仍会 `??0` | §4.5、§7.3:先兼容前端识别未知,再升级运行层/后端,整组镜像到位后验收 | + +## 24 项检查 + +| # | 检查 | 结论 | 证据 | +| --- | --- | --- | --- | +| 1 | 目标可在 1–3 句话说明 | PASS | §1 定义四条链路的一致性修复 | +| 2 | 非目标明确 | PASS | §1、§8 保留不迁移、不回填、不扩其它能力边界 | +| 3 | 特有约束 | PASS | §2 仅定义执行体、两时间面、完整性预算、创建所属租户等 | +| 4 | Constitution 引用及门禁 | PASS | 引用 C1–C8,不重抄铁律;具体核对见下节 | +| 5 | 有关键决策 | PASS | D1–D6 覆盖运行、审计、审批、F048 和创建 | +| 6 | 每项有备选/选定/原因 | PASS | D1–D6 均记录被否方案及证据 | +| 7 | 重审触发条件 | PASS | 每项都有重新考虑条件 | +| 8 | 理由有可核对事实 | PASS | 116 只读证据、基线实际观察/快照/创建复用路径及既有协议 | +| 9 | 输入到输出可快速理解 | PASS | §4.2、§4.3、§4.4、§4.6 四条数据流 | +| 10 | 字段与格式明确 | PASS | §4.5 实例状态、摘要 DTO、空/失败状态与 Web 创建前置字段 | +| 11 | 模块做/不做完整 | PASS | §4.7 职责表,权限模块不依赖业务 ORM,两个 SPA 不混用 | +| 12 | 现状描述与既有合同一致 | PASS | §4.1 固定源码基线;方案均标为拟实施,未把旧 owner-only/v1 注释当当前合同 | +| 13 | 有反直觉事实 | PASS | §5 记录 15 条 | +| 14 | 坑有后果 | PASS | 每条有若忽略的后果 | +| 15 | 坑有处理落点 | PASS | 每条标具体模块/函数或设计小节 | +| 16 | 输出合同充分 | PASS | §6.1 HTTP 增量、内部应用端口与数据类型 | +| 17 | 输入依赖充分 | PASS | §6.3 覆盖绑定、集群/容器、权限、身份、审批和既有存储;无新增系统二进制或服务 | +| 18 | 依赖风险明确 | PASS | §6.3 每项说明哪些变化使其失效 | +| 19 | 单元/集成/E2E 策略 | PASS | §7.1 按边界选测试,§7.2 真实验收;不跑全量 | +| 20 | 可操作手动验证 | PASS | §7.2 给出环境镜像核对、专用身份、页面、配对操作与清理;凭据不入文档 | +| 21 | 不投入项与理由 | PASS | §8,含超大范围、历史采集、复制扩权与跨租户代创建 | +| 22 | 修订记录 | PASS | 初版记录日期、触发与待确认状态,未宣称 Feature 完成 | +| 23 | 与 spec 一致 | PASS | §7.4 覆盖 AC-01–AC-52,合同只读例外和创建归属保持 | +| 24 | 偏差同步 | PASS | tasks 尚未创建,无实施偏差;自审修订均在设计内更新,未倒改已确认 AC | + +## Constitution Check + +| 条款 | 结论 | 本设计核对 | +| --- | --- | --- | +| C1 | PASS | 状态编排留 manager,backend 不持编排 SDK;新 SQL 在所属 Repository,服务层场景调用不跳 endpoint | +| C2 | PASS | 无 DDL、新数据库对象或方言 SQL;沿既有持久层,未伪称已在本机实测 DM8 | +| C3 | PASS | 具体租户及创建所属租户复核,使用既有执行 scope/严格过滤;身份批量读取在所属仓储并只按已绑定人员标识读取 | +| C4 | PASS | 有效动作、投影与继承由 F048 提供;通用 roster 仍要求 manage_permission;审批只读例外先验原资格,没有自建授权或伪装 owner | +| C5 | PASS | 仅预留现有 knowledge 段未占用的 10993,实施前重扫并同步三语;其它情况沿原错误或显式展示状态 | +| C6 | PASS | 无凭据、密钥、真实登录密码或新持久 token;示例均非敏感结构 | +| C7 | PASS | HTTP 仍经各自 API 包装与组件查询,不放 store 直连 HTTP | +| C8 | PASS | 无新本地权威状态或跨进程文件;当前摘要只读查询,不存第二份授权主数据 | + +## 结构与追溯检查 + +- spec 的 AC 定义仍为 52 个连续唯一编号;设计 §7.4 分组覆盖全部编号,未增加或减少 AC。 +- 三个审批详情路由已核对实际 router;新端口/薄入口在设计中标明为拟新增,不伪称当前已有。 +- 设计代码块为合法 JSON/sh 示例,文档链接、章节引用及 HTML 导航在生成评审页时作静态核对。 +- 本轮只更新 SDD 文档、版本契约登记和 HTML 评审稿,未运行业务测试或全量测试;未更新 116、120、189。 + +## 下一步与暂停依据 + +依据 AGENTS.md §6、SDD-Guide §3,正式设计须用户单独确认后才可拆任务和编码。当前停在设计确认点,后续真实部署 E2E 仍须按明确环境授权执行。 diff --git a/features/v3.0.0/115-vibe-runtime-review-fixes/design.md b/features/v3.0.0/115-vibe-runtime-review-fixes/design.md new file mode 100644 index 0000000000..bc804f6584 --- /dev/null +++ b/features/v3.0.0/115-vibe-runtime-review-fixes/design.md @@ -0,0 +1,378 @@ +# Design:F115 应用运行信息、审批当前可见范围与知识库创建 + +**关联**:[已确认 spec](./spec.md) · [已确认 discovery](./discovery.md)。\ +**版本**:v3.0.0;发版线 `feat/3.0.0`。\ +**最后更新**:2026-11-02。\ +**状态**:用户于 2026-11-02 单独确认设计;代码与本地定向验证已实现,真实部署 E2E 尚未执行;52 条规格已确认。本文区分固定基线现状、实施接线和远端部署,不将方案写成已部署事实。\ +**源码基线**:`497256d289793ce016df07cf8ab38f32988faca4`,工作分支 `codex/vibe-runtime-review-20261102`。116/120 只读环境证据沿 discovery,不在设计阶段更新环境。 + +## 1. 目标与非目标 + +补齐当前实例可观测事实,明确模型调用用户筛选空态,在原审批阅读资格内显示当前有效访问授权,并统一本租管的新建知识库资格。沿用现有运行、权限、审批和知识库领域,不新增并列授权规则。 + +非目标沿 spec:不增加监控系统、不恢复 v1 控制、不改现有应用的 OBO 接线、不回填审计或历史审批、不迁移资源、不补写角色菜单和授权;知识库复制、发布审读/预览及其它平台能力不扩权。 + +## 2. 关键约束 + +遵循 [constitution C1–C8](../../../docs/constitution.md)、[release-contract](../release-contract.md) 和前后端各自 AGENTS;全局铁律不在本文重抄。 + +- **两个时间面**:版本、发布说明、声明及审批决定属于冻结内容;可见范围是本次查询的当前授权,只作只读装饰,不写入历史快照。 +- **当前执行体**:必须同时匹配应用、节点、操作和执行引用;K8s 额外校验 Pod UID,不能按名称猜测或选第一个容器。 +- **只读观测**:复用 v2 执行器现有观察和应用探活;Docker 保持现有单次 HTTP 探活的 2 秒超时及判定口径,不增加后台任务、定时器或全新探活请求入口。 +- **完整性优先**:只有完整授权与对象资格查询成功才能产生 `owner_only=true`。投影不就绪、目录服务失败、结果超过本次可完整读取的界限均返回显式不可确认,不输出未经确认的部分名单。 +- **有界读取**:权限来源按既有 500 条游标页读取,本次摘要最多检查 5,000 条来源、最多 5 秒;任一界限触发则整体 `unavailable`,不把截断后的剩余对象当作不存在。该预算仅用于当前摘要,不改变 F048 通用完整授权目录。目录批量解析,不逐成员展开。 +- **创建资格不等于管理视角**:新资源仍落实际自然人与其当前所属租户。新建表单固定打开时的所属租户,实际写入前重验,管理视角不会覆盖此值。 +- 不新增数据库表或列、配置顶层键、队列、权限动作、运行协议版本或监控依赖。MySQL/DM8 通过既有持久层与 dialect helpers 保持兼容。 + +## 3. 方案对比与选定 + +### D1. 用现有执行器观测补齐运行事实 + +- **备选**:只转发 Docker 原生 Health;从历史 AppInstance 补字段;新建定时采样系统;复用现有执行器观察并统一只读状态返回。 +- **选定**:最后一种。在 Docker/K8s `observe` 的结果中补充当前应用容器的计数、启动时间和探活状态;v2 只读状态端点统一转换,后端仅保留公开字段。 +- **原因**:116 的容器有实际启动时间但未设置原生 HEALTHCHECK;基线状态端点漏转时间,而执行器已有实际 HTTP/Ready 观察。历史 AppInstance 回退可能把旧实例信息当成当前值;新增采样系统超出 D1。 +- **重新考虑条件**:后续明确要求长期监控、历史趋势或改变现有探活判定时,另立需求,不借本次显示修复改变发布就绪门槛。 + +### D2. 模型用户查询保持原合同,只补空态与范围切换 + +- **备选**:用负责人补用户;返回全租户人员;新增历史回填;保持真实记录查询并修前端状态。 +- **选定**:保留现有候选和记录接口、范围校验、分页及真实 `user/natural_person` 过滤;使用现有共享 Select 的 `emptyText/noMatchText/loadingText`,在业务层区分失败和重试。 +- **原因**:116/120 当前无应用限制时已返回真实用户候选,截图应用只有应用自身调用;未复现候选接口整体为空。补负责人会改变历史身份事实。现有 Select 已具备相应文案入口,无需改共享控件或候选数据结构。 +- **重新考虑条件**:后续需要真实用户姓名快照、更多身份筛选或新的 OBO 数据采集,单独评估来源和历史兼容;不由本功能推测。 + +### D3. 审批校验之后添加当前摘要,不覆盖冻结快照 + +- **备选**:重建 `detail_snapshot`;让客户端调用通用授权列表;为审批人临时赋 `manage_permission`;在原详情读取通过后添加独立的 `current_visibility`。 +- **选定**:最后一种。审批服务先完成原阅读校验,再经现有场景 Handler 工厂调用应用发布场景的只读补充方法;其它场景不增加业务行为。新字段与两个历史 snapshot 同级。 +- **原因**:基线任务、个人实例及管理实例详情均直接返回保存的 snapshot。直接覆盖会让冻结内容与当前事实混淆;通用授权列表要求长期 `manage_permission`,不适合已确认的窄审批例外。 +- **重新考虑条件**:另有场景提出相同的动态展示需求后再评估共性。本次只增加一个可选场景补充钩子,不建立新的审批视图框架。 + +### D4. 当前范围读取 F048 有效动作来源,身份目录由业务侧补充 + +- **备选**:按 model 名称/level 判断;业务侧查询 Grant/OpenFGA;枚举全租户每个人做 Check;由 F048 提供可信服务端有效动作来源读取,再批量核对对象资格。 +- **选定**:最后一种。权限应用端口提供目标资源 `use` 的有效来源;复用 CURRENT Catalog、当前投影和继承规则。现有租户主体目录新增批量只读解析,返回有效、已失效、不可确认三类结果和显示名称。 +- **原因**:已有可信服务端 `list_effective_direct_user_ids_by_model` 说明来源读取不必借管理页身份,但它只支持直接人员和指定模型,不能直接满足本功能。通用 `list_grants` 的管理门禁保持;新增窄读取不提供 HTTP 入口、不伪造 owner/超管。 +- **重新考虑条件**:权限模块正式提供同等语义的完整 action 来源公共协议时改用该协议;不保留两套来源判定算法。 + +### D5. 仅放开知识库新建,不修改全局创建菜单 + +- **备选**:给租管批量加菜单;将 `create_knowledge` 全局并入租管菜单;仅改按钮;知识库新建使用独立资格策略并复用原创建流程。 +- **选定**:最后一种。前端区分 `canCreateLibrary` 与 `canCopyLibrary`;后端 Web 新建入口执行本功能的创建资格策略,复制仍保留原菜单及源资源读取门禁。 +- **原因**:两类知识库页面当前将新建资格复用于复制;后端 create/copy 都要求原菜单。单改页面或全局加菜单会分别导致继续拒绝或顺带扩大复制权限。 +- **重新考虑条件**:以后明确要求统一所有资源的创建/复制能力时再做菜单策略调整,不把本次例外推广到其它资源。 + +### D6. 复用真实 Web 身份校验,并把表单租户作为前置条件 + +- **备选**:信任登录响应的 `is_child_admin`;按页面选中管理租户写入;修改共用 KnowledgeCreate 的所有调用者;Web 新建请求携带表单预期租户,服务端按当前真实身份检查。 +- **选定**:最后一种。仅 Web create 请求增加可选 `expected_tenant_id`;两个新建对话框均固定发送打开时的 `leaf_tenant_id`。服务器不把该参数当作目标租户,只用于与已验证所属租户比较;写入者仍为真实登录自然人。 +- **原因**:JWT/表单可能跨越人员迁租户和资格变化,页面管理视角又不同于人员归属。已有 `user.application.runtime_identity.resolve_web_runtime_identity` 可验证多租户下的人员、唯一有效租户及会话版本;新建用途在同一应用模块增加薄入口处理单租户,不改延迟运行任务原有严格合同,不新增身份系统。 +- **兼容**:旧请求缺少该字段时,以其经验证 JWT 租户作预期值,仍须与实时身份匹配;新页面显式固定预期值,可拒绝表单打开后换会话/换租户的提交。共享知识库请求及开放 API 不因此新增菜单门禁。 +- **重新考虑条件**:未来正式支持跨租户代创建,须另审创建归属和防陈旧合同,不能放宽此处比较。 + +## 4. 固定基线与本地实施接线 + +### 4.1 固定基线现状 + +| 链路 | 497256d28 的真实接线 | 缺口 | +| --- | --- | --- | +| 运行状态 | `app_query_service.get_instance` → `orchestrator_client.status` → v2 `platform_observe.status` → 当前绑定检查 | Docker 只读原生 Health;两形态漏启动时间;缺失计数默认零;后端找不到执行体时回退旧 AppInstance | +| Docker/K8s 观察 | `executors/docker.py::observe` 已发单次应用探活;`executors/kubernetes.py::_observed` 已核对 Pod Ready 与 EndpointSlice UID/IP | 返回未覆盖完整显示事实;K8s 找不到 app 状态时求和为零 | +| 模型用户候选 | `runtime_audit_query_service.candidates` → `runtime_audit_repository.statement/candidates`;API `candidates/visitor?kind=model` | 数据范围与真实身份过滤已存在;Select 使用通用空文案,失败提示与空列表可能同时出现 | +| 审批详情 | `approval_center_service.get_task_detail/get_instance_detail` 先校验资格,再返回 `detail_snapshot/payload_snapshot`;app Handler 仅复制空的 `visibility_snapshot` | 缺当前授权展示,客户端仅解析冻结 snapshot | +| 权限来源 | `F048ResourcePermissionApi.list_grants` 和 runtime `list_permission_sources_page` 要求 manage_permission;`control_state.load_source_page` 处理 CUSTOM/INHERIT 与 CURRENT 来源 | 不能让普通审批阅读者直接调用通用 roster;无按 action 返回当前人员/组/部门摘要的端口 | +| 对象目录 | `TenantPermissionSubjectDirectory.canonical_source/display_names` 已验证或显示对象 | 现有人员 canonical 校验关注有效成员,不能直接当作“人员未停用且名称完整”的完整摘要证明 | +| 知识库 | `knowledge.create_knowledge` 先断言菜单,再调用同步创建;Doc/QA 两页面相同条件控制新建和复制 | 租管路由可见与新建资格不一致;表单没有固定创建租户前置条件 | + +基线注释中的历史 owner-only 或长期 v1 说明不能覆盖 F114 当前合同。`orchestrator_client.status` 中仍出现 `/v1/` 字符串,但 `_request/_v2_request` 强制 v2 并改写路径,不能据此重新接通 v1。 + +### 4.2 运行状态:沿当前绑定观察 + +`发布页既有实例查询 → 原 F114 状态阅读门禁 → 后端只读状态 RPC → 当前绑定校验 → 执行器 observe → 统一事实 → 显示`。 + +1. `platform_observe.status` 只读加载当前 `app_id/generation/operation_id/exec_ref/node_id`。Docker 校验 app、操作、节点及 v2 管理标签;K8s 校验应用、操作、节点、Pod UID 和应用容器名 `app`。 +2. 将显示所需事实补入执行器观察结果。Docker 从同次精确容器 inspect 取得 `State.StartedAt` 和 `RestartCount`,健康仍由已有应用 HTTP 探活形成;原生 Health 可作诊断,不能替代统一的应用就绪语义。K8s 仅从匹配 `app` 的 `containerStatuses` 取得重启与当前 `state.running.startedAt`,当前状态为 terminated 时可取其 `startedAt`;不读 initContainer 或 `lastState` 冒充当前。 +3. `observe` 原 `ready/running/target` 语义供生命周期继续使用;新增 `probe_state` 区分已成功、已失败、尚无结果,不改变原 `<500` 的 HTTP 判定和发布就绪规则。探活返回失败和探活基础设施不可访问是不同结果;无应用地址、状态缺失不得默认为探活成功。 +4. 状态返回前再次确认绑定的 generation、operation、exec_ref(K8s 含 UID)未变化;变化则报告暂不可确认,刷新再读,不能混合新旧事实。不修改平台绑定或尝试自愈。 +5. Docker `Restarting`、K8s Pending/ContainerCreating 或当前操作 `starting` 且没有完成判定时显示启动中。已有明确探活失败则为异常;Ready/探活成功为正常;缺少有效判定为未知。执行体已停止时 phase 为 stopped,不显示仍健康。 +6. 无绑定与绑定的执行体丢失是正常可解释的两种只读结果,见 §4.5。调用错误、归属不匹配、平台/集群不可用走原 16121 故障链。后端 `get_instance` 不再用旧 AppInstance 的健康、计数、启动时间补“当前值”。 +7. 启动时间统一输出带时区的 ISO-8601 UTC;空值、Docker 未启动零日期、无效时间为 null。计数只有明确的非负整数才有效,布尔/缺失/非法值为 null;真实 0 保留。前端先验证时间,再复用 `displayTimestamp.formatTimestamp` 转浏览器本地时区,不改全局时间工具。 + +### 4.3 访问用户:只改状态与查询范围生命周期 + +`当前具体租户与筛选条件 → 原候选 API → 真实自然人记录候选 → 选择后原记录 API`。 + +- `CandidateFilter` 继续通过 `controllers/API/runtimeAudit.ts` 获取候选,不新增数据库查询、候选字段或历史身份来源。 +- 空搜索 + 查询成功 + 空候选:显示“当前范围没有访问用户记录;应用自身调用不代表自然人访问”。有搜索 + 成功空候选:显示无匹配用户。加载中使用 Select loading;失败时使用失败文案和重试按钮,空提示不得同时宣称无记录。 +- `emptyText/noMatchText/loadingText` 覆盖该过滤器,不改变共享 Select;真实 ID 原样用于过滤,不把 app/self ID 转成人名。 +- kind/具体租户/应用/时间等范围变化后重置该过滤器搜索与不再适用的 visitor 选择;请求 key 含完整新范围,不使用旧范围 placeholder 数据。读取途中切换范围的旧响应只能留在旧 key,不回写新状态。 +- 候选继续使用后端的同一授权范围及其它过滤条件;访客候选无需扩展成全租户人员。保留现有分页,列表和导出仍使用原冻结筛选合同。 + +### 4.4 审批摘要:先取得阅读资格,再读取当前授权 + +`个人任务/个人申请/管理详情 → 原审批阅读校验 → app 发布 Handler 只读补充 → 业务核对 app/tenant → F048 action 来源 → 业务目录核对 → current_visibility`。 + +#### A. 原阅读门禁与绑定 + +- 在 `get_task_detail`、`get_instance_detail` 的原租户、任务/申请归属或管理资格检查**之后**调用补充方法。失败的原鉴权不得进入补充逻辑,不能用 `app.publish` 代替审批阅读资格。 +- 通过 `approval_runtime_handler_factory` 为 app 场景取得新 Handler,调用新增可选 `read_current_detail`;无此方法的其它场景不增加任何查询。注册和调用均在服务层,不在 endpoint 跨模块导入。 +- 审批模块创建进程内的已验证阅读上下文,包含 instance、scenario、tenant、business_resource_id、当前阅读者和读取用途;不是 HTTP 可提交的对象,不存储凭据,不注册新对外 endpoint。 +- 发布场景的新增 `app_publish_current_visibility_service.py` 从该上下文取得应用 ID,核对保存的业务资源绑定及当前应用租户;若 payload 存在 app_id,必须与业务绑定一致。审批申请人、客户端传的 app_id 或历史 owner 名称不能替代绑定检查。 +- 正常目标由 app 业务适配器生成 `VerifiedPermissionTarget`;不伪造负责人或超管 actor。个人审批沿所属租户;管理详情仅允许已通过原管理门禁的具体租户。应用删除单独映射为 deleted;跨租户/绑定不一致不返回对象名单。 + +#### B. F048 的窄只读来源端口 + +- 在 `permission.application` 增加 `read_effective_action_sources(target, action)` 的可信服务端端口,内部委派 F048 runtime。它不替调用方判断审批资格,不接受客户端“已授权”标记;仅供已验证业务目标和前置业务阅读资格后的服务端调用,首个调用点限定为上段应用发布补充服务。 +- 复用 runtime 当前 Catalog/Store/model pin、`_require_current_target` 和权限内部 `load_source_page`;选取当前有效且 `action_codes` 含 `use` 的模型,不按名称或 level 推断。每页读取在权限持久层完成,业务方不查询 Grant 或 OpenFGA。 +- CUSTOM 取本地有效来源;INHERIT 复用既有最近自定义祖先来源与本资源受保护创建者语义,不引入第二套继承逻辑。目前 app 固定 CUSTOM,仍为可复用端口保留现有继承合同的配对验证。 +- 必须验证目标及实际涉及祖先投影 CURRENT、版本一致,并在完整分页结束后复核版本与 Catalog 未变化。PROJECTING/FAILED_CLOSED、游标不前进、未知模型或不完整来源均不能变成完整空集合;超预算返回不可确认。 +- 通用 `list_grants/list_permission_sources_page` 的 `manage_permission` 保持原样。新端口不包含授权写入、完整 model/Grant 元数据、编辑能力或来源秘密,不提供给通用 HTTP roster 使用。 + +#### C. 名称与有效资格 + +- 经现有 `get_grant_subject_directory` 取得业务侧目录,由 `TenantPermissionSubjectDirectory` 增加批量只读解析方法。新增 SQL 只放所属领域 Repository,权限模块仅依赖应用协议,不导入用户/部门/应用 ORM。 +- 多租户开启时人员必须未停用且只有一个有效当前人员租户,与应用租户一致;关闭时按有效自然人/tenant=1 语义验证,不要求额外成员行。user 应用模块提供批量 `read_current_person_facts(user_ids)`,只输出当前人员事实,不要求也不制造这些人员的会话;Web 创建身份入口复用这些事实并额外验证真实会话。已明确失效的人员来源排除。部门/组需确认仍存在且属于该租户;批量读取失败、人员所属不能唯一确定或无法确认名称时,整个摘要 unavailable。 +- 组与部门保留 userset 含义:部门可注明含下级,用户组可注明仅管理员;不展开成员,也不承诺组内每个人都可以访问。按 `(type,id,userset_relation,include_children)` 去重,不重复同一来源的显示。 +- 服务账号来源跳过普通访问人员列表;管理员隐式访问只显示统一说明。发现当前合同之外、不能用人员/部门/组准确表达的有效来源(如历史 tenant 来源)时不得丢弃后宣称 owner-only,返回不可确认并记录安全原因。 +- 负责人当前资格使用真实当前人员和 F048 `use` 判定,不仅看 protected creator 行或历史快照;该人员失效则不补列“负责人可见”。构造其 PermissionActor 时只使用该人员当前有效成员、真实全局/租户管理员事实(原身份应用入口及 `is_tenant_admin` 查询),不调用可能返回当前请求阅读者缓存的 `resolve_permission_actor` 来代验另一人;也不临时给予管理员字段。单独查询负责人事实不等于代其执行业务。有效 owner 若不在普通来源列表则仅为展示去重补入,不补写授权。 +- 全部成功后才计算 owner_only:负责人有效,且不存在负责人以外的有效普通访问对象。完整成功但负责人无资格且无其它对象显示“当前没有有效普通访问对象”。 + +#### D. 失败隔离与页面 + +- `detail_snapshot/payload_snapshot` 原样返回;当前摘要是独立字段。过往空占位不回填,后续新审批也不把当前范围写入冻结快照。 +- 只读补充是明确的非关键展示步骤:其内部依赖异常记录完整服务端异常并返回 sanitized unavailable,不吞成成功空名单;原阅读鉴权和场景绑定检查不放进该降级捕获。未知程序异常同样留错误日志,前端看到不可确认,不泄露异常正文。 +- 客户端 `api/approval.ts` 扩展详情类型,`AppPublishDetailPanel` 只从 `current_visibility` 读取该区域,其余区域继续读 `detail_snapshot`;字段缺失的旧服务端显示“暂不可确认”,不回退旧快照冒充当前。 +- 打开详情重新查询,提供当前详情刷新回调;终态也读当前摘要。平台异常列表已有管理详情在 `ExceptionPanel/useApprovalExceptions` 中缓存,补充只读区域并在展开/刷新时重读当前管理详情,缓存按具体租户与 instance 隔离,不将终态缓存永久视作当前。 +- client/platform 各自使用本应用 API 包装与组件组合,不跨 SPA 复制状态或直接导入另一应用组件。平台只新增小型摘要区域,不复制整个客户端审批卡。 + +### 4.5 关键对外字段 + +#### 实例状态(原 `GET /api/v1/apps/{app_id}/instance`,新增/收窄字段含义) + +```json +{ + "instance_id": "current-execution-ref", + "phase": "running", + "health": "healthy", + "restart_count": 0, + "started_at": "2026-10-30T10:33:15.363Z", + "last_probe_at": "2026-11-02T02:00:00Z", + "observation_state": "current" +} +``` + +| 字段 | 类型与约束 | 消费规则 | +| --- | --- | --- | +| `phase` | 沿既有 pending/building/starting/running/unhealthy/stopped/failed | 应用业务状态不由此改变;观察失败不伪装已删除 | +| `health` | healthy / unhealthy / starting / null | null 显示未知;非法旧值也显示未知,不原样露英文 | +| `restart_count` | 非负整数或 null | 类型改为 nullable;禁止 `?? 0` | +| `started_at/last_probe_at` | 带时区 ISO-8601 或 null | 无有效值显示未知/尚未启动;不输出零日期或用当前查询时刻冒充启动时间 | +| `observation_state` | current / no_execution / execution_missing | no_execution:无绑定,phase stopped、instance_id null、三项事实 null;execution_missing:绑定有引用但执行体丢失,phase unhealthy、保留引用、事实 null。真正依赖故障返回错误 | + +manager `/v2/apps/{app_id}/status` 使用相同语义,保留内部 app/generation 信息,后端白名单只增 observation_state。AppNotFound 回退不再读取旧实例事实;新 manager 对“无执行体”给出上述明确结果,旧 manager 的模糊 404 只显示无法确认,不能恢复历史事实。旧服务端缺少 observation_state 时新前端将三项事实作为尚未确认的新合同信息显示未知,不把旧默认零值或历史回退当作已验证当前事实;应用业务状态仍独立显示。 + +#### 审批详情的同级增量 + +```json +{ + "current_visibility": { + "state": "available", + "checked_at": "2026-11-02T02:00:00Z", + "owner_only": false, + "owner": {"id": "849", "name": "负责人示例", "eligible": true}, + "subjects": [ + {"type": "user", "id": "849", "name": "负责人示例"}, + {"type": "department", "id": "60", "name": "部门示例", "userset_relation": "subtree_member", "include_children": true}, + {"type": "user_group", "id": "12", "name": "用户组示例", "userset_relation": "admin", "include_children": false} + ], + "admin_implicit_access": true, + "reason": null + } +} +``` + +- `state=available` 仅代表完整确认;owner_only 为布尔。`owner` 是当前负责人信息,不能从冻结名称推断有效资格。 +- `state=deleted|unavailable` 时 `owner_only=null`、`owner=null`、`subjects=[]`;空数组不能在前端解释成仅负责人。reason 仅为固定安全值:application_deleted、application_unavailable、authorization_unavailable;不返回 SQL、FGA 响应或身份目录详情。 +- 新前端始终提示“当前可见范围,授权变更即时生效,不影响审批”及管理员隐式资格说明。已有审批同意/拒绝按钮、审读及预览入口继续遵循原能力,不由当前摘要推导。 +- JSON 示例只是结构示意,不包含真实凭据或对示例 ID 的权限承诺。 + +#### 其它接口 + +- `GET /api/v1/app-runtime-audit/candidates/visitor?kind=model`:请求/响应不变,仍为 `items[{id,label,deleted_or_revoked}],has_more`;记录与导出合同不变。 +- `POST /api/v1/knowledge/create`:Web 专用请求在既有字段外增加可选正整数 `expected_tenant_id`。它不是落库字段,不接受由此指定任意租户;Doc/QA 两对话框均发送打开时的所属租户。 +- 三个审批详情入口:`/api/v1/approval/my-tasks/{task_id}`、`/api/v1/approval/instances/{instance_id}`、`/api/v1/approval/admin/instances/{instance_id}` 只增加同级字段,不改原 snapshot;已核对 `approval_user.py` 的 router 与路径,不新设别名。 + +### 4.6 知识库新建接线与写入前重验 + +`Doc/QA 表单固定所属租户 → Web create endpoint → 当前 Web 身份/创建资格 → 原校验 → 写入前再验 → 原创建/投影/索引流程`。 + +1. 新增 `knowledge_creation_policy.py`,作为 Web 新建专用领域资格策略,消费真实登录会话的服务器引用,并调用 `user.application.runtime_identity` 新增的 `resolve_web_creation_identity` 薄入口。多租户开启时复用既有 `resolve_web_runtime_identity` 的唯一有效成员与租户校验;关闭时由同一 user 应用模块按有效人员、会话期限及 token_version 验证并固定 tenant=1,不要求旧单租户人员凭空补一条 UserTenant。新增读取仍在 user 所属 Repository,不放业务 SQL 到策略服务。最后比较 expected/JWT/实时所属租户;身份不确定、租户停用或会话失效按原身份错误拒绝,既有 runtime 身份函数语义不改。 +2. 同一真实自然人需具备以下之一:原有效超管/创建菜单资格;或当前经 `permission.application.is_tenant_admin` 确认的本租户管理员资格。部门管理员和界面 is_child_admin 不成为后端授权来源。依赖故障不能静默授予租管例外。 +3. 新建资格传递的是服务端创建的短生命周期上下文(人员/所属租户/会话版本/预期租户),不是持久对象或客户端 capability。endpoint 调用已有异步 `KnowledgeService.acreate_knowledge`,仅 Web 路径携带此上下文。 +4. 原名称、embedding、租户与其它输入检查后,在 `acreate_knowledge` 进入 `acreate_knowledge_base` 之前重验该上下文和当前资格。失败时没有业务行、索引、FGA Grant 或后续任务。开放 API/其它内部调用不携带该 Web 上下文,继续原通道资格,不能被新增菜单检查误拦。 +5. 整个 Web 创建使用既有 `tenant_execution_scope(verified_tenant)` 配合 `strict_tenant_filter()`,退出即恢复 ContextVar,不能把它误写成接受 tenant 参数的 strict 过滤函数。用户 ID 与租户 ID 仍由服务器覆盖为已验证自然人及其所属租户;客户端 user_id、管理范围和预期租户不能成为写入 authority。继续原创建者投影、失败补偿、索引初始化和审计;不添加菜单或角色。 +6. 前端页面 `canCreateLibrary=原有菜单/超管资格 OR 当前 is_child_admin`;`canCopyLibrary=原有菜单/超管资格`,再叠加现有源读取条件。表单在打开时固定当前 `leaf_tenant_id`,不是 `useAdminScope` 的租户;该值尚未加载时不提交,刷新身份信息,只有服务端确认单租户模式时才固定 1。资格变化时按钮可能等待用户信息刷新,但实际写入始终拒绝过期资格。 +7. expected 与当前所属租户不一致使用 §6 的专用前置条件错误,提示刷新表单;不要误用管理视角错误或悄悄改写到新租户。 + +### 4.7 模块职责与影响面 + +| 现有/拟新增模块 | 做什么 | 不做什么 | +| --- | --- | --- | +| runtime-manager `executors/*`、`api/platform_observe.py` | 精确执行体观察、nullable 事实及统一状态 | 不恢复控制 v1、不触发修复、不持久化第二份应用主数据 | +| backend `app_query_service.py`、`orchestrator_client.py` | 原阅读门禁、公开字段、区分故障和无执行体 | 不调用 Docker/K8s SDK、不回填旧实例值 | +| platform `PublishTab`、`hostedApp.ts` | 状态、未知、零与有效时间展示 | 不按 UI 猜测健康、不改变全局时间语义 | +| platform `RuntimeAuditPanel.tsx` | 原查询、独立状态提示与范围重置 | 不改历史账本、SDK、身份推断或共享 Select 视觉 | +| approval 服务与 Handler 工厂 | 原鉴权之后分派只读补充 | 不认识 F048 Grant 或修改审批决定/路由 | +| 拟新增 app_publish 当前摘要服务 | 核对 app/tenant/instance 绑定、协调 F048 来源与目录、输出小 DTO | 不写 snapshot、不授予权限、不直接查权限表 | +| permission.application 端口与 runtime/control_state | 现有 Catalog/投影/继承下的 action 来源与完整性 | 不依赖审批、用户或应用业务 ORM,不暴露新的 HTTP roster | +| tenant 主体目录及所属 Repository | 批量当前对象资格与名称 | 不展开所有成员、不替代 F048 动作判定 | +| knowledge Web 创建策略及原异步创建流程 | 当前身份/租户和创建资格、写前重验 | 不更改复制/开放通道资格、角色菜单或资源归属 | +| user.application.runtime_identity 与所属 Repository | 复用当前人员事实,新增批量读取和 Web 新建用途薄入口;兼容关闭多租户 | 不新增身份来源、不为负责人制造会话、不改变原运行/预览严格入口 | +| client 审批面板 / platform 异常详情 | 各自渲染当前范围与重查;其余字段仍冻结 | 不在两个 SPA 之间混用状态、API 包装或页面组件 | + +三语键分别落各应用现有 locale;新增业务错误对应 `packages/locales/src/api_errors/{zh-Hans,en,ja}.json`。实施时同步 approval-module skill 的详情 API 和场景补充接线说明,不更改审批人解析规则。 + +### 4.8 本地实现补充(2026-11-02) + +- `user.application.runtime_identity.read_current_person_facts` 及 `resolve_web_creation_identity` 已接线;新建用途区分所属租户变化,原 runtime/preview 身份入口未改。 +- F048 `SqlPermissionControlState.source_read_stamp` 经仓储读取继承链的模式/Grant版本,`read_effective_action_sources` 完整分页后再次复核模式与Catalog。业务摘要在名称、负责人资格查完后再读一次来源,变化返回 unavailable。 +- `AppPublishCurrentVisibilityService` 由原审批门禁后的 app Handler 调用,超时预算5秒;目标 app/tenant 校验先于删除态和目录读取。DTO 使用 `userset_relation`(含 subtree_member),两端按成员、管理员、含下级分别显示。 +- 新建 endpoint 只使用验证过的真实所属租户,async create 在模型和重名检查后、首个写入前重验会话及资格;Web 专用 `expected_tenant_id` 不扩展共享 KnowledgeCreate。 +- 管理异常页重复展开、显式刷新均重查,旧异步响应用序号丢弃;管理租户切换沿既有 `bisheng:management-scope-changed` 全页重载清空内存。客户端真实父层为 `ApprovalPane.tsx`,刷新仍调用原详情 API。 +- 知识库表单缺少所属租户时阻止提交并提示刷新重开,不推测为1;服务端单租户 user/info 已有确认的1语义时才可发送1。新建与复制条件已分开。 +- manager 状态采用显式 no_execution/execution_missing/current;已停止执行体不保留失败探活相位,归属变化或平台资源读取失败不伪装成执行体丢失。K8s专用只读observe_status仅将精确Pod 404识别为丢失;原observe生命周期入口不变,Service/Endpoint等依赖失败仍不可确认。 +- 未新增表、权限动作或外部依赖。新增10993及三语产物已落地;E2E脚本仅显式开启后访问真实环境,创建另需写入开关,清理校验精确名称、人员和租户。 + +## 5. 已知坑与处理 + +| # | 事实 | 若忽略会怎样 | 处理处 | +| --- | --- | --- | --- | +| 1 | 有运行容器不等于配置了原生 Health;平台已有独立应用探活 | 继续显示空健康,或把 Running 冒充健康 | manager observe/status,D1 | +| 2 | K8s app 状态缺失时现有 sum 默认为 0 | 把未观测到数据当成真实零 | K8s `_observed` 与 nullable 映射 | +| 3 | 后端无实例回退会读旧 AppInstance | 旧时间和健康出现在已替换实例上 | `app_query_service.get_instance` | +| 4 | v1 字符串在只读客户端内被强制改成 v2 | 为修显示重新接通已退役控制协议 | `orchestrator_client._v2_request`,保持 v2 | +| 5 | 模型应用自身调用没有自然人 subject;候选目前已有真实数据 | 补 owner 会篡改审计含义 | 审计仓储保持;过滤器补空态 | +| 6 | 授权模型名称/level 不等于当前 action_codes | 误把只有 edit 的对象当访问者 | F048 action 来源端口 | +| 7 | 承载源的投影中间态、分页截断不能当完整空集合 | 错误宣称“仅负责人可见” | CURRENT/版本前后校验、有界失败显式 unavailable | +| 8 | 现有 canonical_source 对人只核成员行,display_names 只装饰名称 | 已停用或迁租户人员仍列为有效 | 主体目录新增批量资格解析,不直接复用为完整证明 | +| 9 | 草稿/下线不清空授权;负责人可能仍记在历史单但已失去资格 | 首发永远查不到范围,或无效负责人被认定可见 | app 业务目标与真实 owner use 分离 | +| 10 | 部门 subtree、用户组 admin 与整个对象成员含义不同 | 文案把仅部分 userset 授权夸大成全部成员 | DTO 保留 relation/include_children,前端标注 | +| 11 | 异常进度按 exception ID 缓存详情 | 授权变化后仍永久显示旧范围 | `useApprovalExceptions` 按 scope/instance 重查 | +| 12 | 新建条件同时控制复制,页面租户也不等于创建归属 | 一次补按钮造成复制扩权或写到所选租户 | Doc/QA 条件分离,表单 expected tenant + Web 策略 | +| 13 | 同步与异步知识库创建、开放通道共用底层创建逻辑 | 将 Web 菜单门禁加到 SDK 或重复创建流程 | 仅 Web 入口带上下文,复用 `acreate_knowledge` 与原底层 | +| 14 | 运行任务身份函数要求有效 UserTenant;旧单租户用户未必有该行 | 复用后把原来可创建的人全拒绝 | user 应用模块新增用途入口,关闭多租户时固定 1;原 runtime 严格合同不改 | +| 15 | `resolve_permission_actor` 优先返回当前请求 actor | 查负责人时错误使用阅读者的超管/普通身份 | 负责人按自身事实构造 actor,不能借请求缓存代验 | + +## 6. 对外契约与依赖 + +### 6.1 提供与兼容 + +| 提供项 | 形式/消费者 | 兼容与限制 | +| --- | --- | --- | +| 当前运行事实 | 原 manager v2 status、平台 instance API | 字段增量;计数 nullable。manager/backend/frontend 一起升级后才算运行信息交付;旧字段缺失表达未知,不保留错误回退 | +| `current_visibility` | 原三个审批详情,client 面板和 platform 管理详情 | 同级增量;旧客户端可忽略,但不会获得本功能新显示;新客户端对旧后端显示未知,绝不把旧 snapshot 冒充当前 | +| `read_effective_action_sources` | permission.application 可信服务端协议 | 需要业务验证目标与既有前置阅读资格;仅摘要用途首个调用点,通用 roster 管理门禁不变 | +| `read_current_detail` | app 发布 Handler 可选补充 | 只读,非 app 场景没有补充方法时返回无增量;不改 resolver/outbox | +| `expected_tenant_id` | Web knowledge/create 请求增量 | 只作前置条件,不是跨租户创建能力;旧调用以已验证 JWT 租户作预期,不改变共享开放 API 请求 | +| 知识库 Web 创建策略 | 原 endpoint 与已有异步创建服务 | 不扩大复制与其它通道;前置失败无创建副作用,成功沿原投影/索引/审计 | + +### 6.2 错误与展示状态 + +| 情形 | 合同 | +| --- | --- | +| manager/平台/集群不可用或执行归属不匹配 | 复用 `AppOrchestratorUnavailableError` 16121,保留安全 reason;不转换成无应用 | +| 原审批无阅读资格 | 原 `ApprovalRequestPermissionDeniedError` 18101;在任何摘要查询前拒绝 | +| 授权投影/目录失败或摘要超预算 | 当前区域 `state=unavailable` 与固定 reason;日志留异常,冻结详情保持;不新增独立错误码 | +| 应用已删除 | 当前区域 `state=deleted`,保留合法可读历史;不恢复应用 | +| 当前 Web 身份失效/无创建资格 | 沿原身份及权限错误;前端统一请求层处理 | +| 新建预期所属租户与实时身份不一致 | 拟新增 `KnowledgeCreationScopeChangedError` **10993**,文案明确所属租户已变化,请重新打开表单;本次基线全部错误码扫描未占用。仅此一条新业务码,实施前重扫;若已被其它变更占用,选未占用码并同步本文与三语,不能复用冲突码 | + +10993 登记在 knowledge 现有模块及版本契约,不新占模块;`/api/v1` 沿原业务错误 envelope,不擅自修改全站 HTTP 状态。未新增密钥、凭据、敏感字段或真实账号密码。 + +### 6.3 依赖与风险 + +| 依赖 | 当前用途 | 变化风险 | +| --- | --- | --- | +| 当前应用绑定及 manager 执行器 | 当前事实与安全边界 | exec_ref/UID 失配或旧 manager 不能证明当前值,必须未知/错误,不能降级旧绑定 | +| Kubernetes Pod/Ready/EndpointSlice;Docker inspect 和已有 HTTP 探活 | 精确当前应用容器 | API 失败不得等同负探活;新的容器名或 readiness 口径需同步观察测试 | +| F048 CURRENT Catalog/Store/model/投影 | action 与完整来源 | pin 不一致、重发布/投影中必须不可确认;本功能不运行修复或迁移 | +| 主体目录、人员唯一当前成员与租户状态 | 名称、对象资格、创建身份 | 目录失败与已失效不同,不能用名称存在推断资格;业务层批量解析保持租户上下文;单租户关闭模式固定 1,不要求额外成员行 | +| 审批个人与管理阅读门禁 | 窄只读例外前置资格 | 不能将摘要服务直接暴露到只知道 ID 的请求,也不把 admin 进度资格变成同意权限 | +| 原知识库异步创建及投影补偿、embedding 回退 | 复用实际创建 | 不复制创建算法;新策略不得影响开放 API 默认模型或复制路径 | +| 共享 Select / 各应用现有 API、i18n、时间工具 | 文案与交互 | 不改共享组件视觉、不新增状态库;两个 SPA 不混用,三语同时交付 | +| MySQL/DM8、OpenFGA、Redis、现有索引服务 | 原平台依赖 | 无新增服务或本地权威存储,无 DDL;DM8 兼容由审查及中央环境回归验证,不伪称本机已实测 | + +## 7. 定向验证与可观测 + +### 7.1 本地验证策略(实施后执行) + +只覆盖本功能改变的行为和具体风险,不运行仓库全量测试。新增 backend 测试放所属 `test//`;没有业务代码的设计阶段不跑业务测试。 + +| 层 | 必要验证 | +| --- | --- | +| manager 单元/接口 | Docker 有/无 Health 的真实探活、启动/异常/未知、0/正数/null、未启动日期;K8s app/init 区分、Ready/Endpoint UID、缺 containerStatuses;停止/丢失/切换绑定与查询无写副作用 | +| backend 状态 | 只读字段和 nullable、依赖错误 vs 无绑定、拒绝旧 AppInstance 数据回退、原状态阅读权限不变 | +| 审计后端/前端 | 原真实身份/授权范围配对;app-self 与人混合时候选/筛选;空、搜索空、加载失败重试;切租户和过滤条件后的旧响应不污染 | +| permission/目录 | use 动作与 model 名称分离、直接与继承、projection/catalog 切换、分页完整性/预算、停用迁租户、对象失效/服务失败、userset 不展开、未知 tenant 来源不误报 owner-only | +| 审批服务与页面 | 先鉴权后补充;有任务无长期 manage_permission 可读摘要但通用 roster 拒绝;进行中/终态、个人/管理三入口,owner-only/多类对象/负责人失效/删除/故障;snapshot 深度相等、其它三个审批场景不增加摘要调用 | +| 知识库 | 无菜单租管 Doc/QA 成功;普通无菜单拒绝/有菜单成功;部门管理不新增资格;新建与复制分离;迁租户/停用/撤资格/会话变化前置拒绝;关闭多租户且无成员行仍保持原创建资格;原 embedding/索引与创建者投影不绕过 | +| 三语与静态 | 变更文件 lint/typecheck/相关组件测试、三语键及错误码冲突检查;不因修文案引入全量回归 | + +后续测试命令以明确文件选择执行,例如: + +```sh +# 在 src/backend 中,仅指定本功能新增/受影响用例;不传整个 test/。 +uv run pytest test/app_runtime/test_app_query_service_entry.py test/app_governance/test_runtime_audit_query.py +# 新增的摘要、Web 创建资格用例在任务阶段列出精确文件名。 +# manager 在 src/runtime-manager 中选择 v2 状态与 K8s 观察用例,不跑已退役 v1 测试作交付证明。 +python -m pytest tests/test_operations_v2.py tests/test_k8s_observe.py +``` + +这些是待实施验证入口,不是本轮已运行的记录;最终命令、环境、数量和失败基线记 verification。 + +### 7.2 必需真实 E2E 与操作步骤 + +真实验收仍是 SDD 必需步骤,但本轮只确认设计,不自动授权环境服务更新或业务写入;镜像、质量门禁及账号准备后按用户另行授权执行一次本功能定向验收。 + +1. 核对 116 的 backend/worker/platform/client 与 runtime-manager 实际镜像和本次提交;由用户授权更新相关服务后再测,不将旧 F114 manager 当作新状态合同的证明。 +2. 使用专用 `e2e-f115` 人员、租户、应用与可清理知识库,不修改 gzx01 或三个存量应用归属;凭据经安全环境注入,会话仅进程内。 +3. 在 `[116:3001]/build/apps/{test_app}` 核对健康、重启次数、浏览器本地启动时间与精确容器事实一致;用专用应用验证停止/无执行体;K8s 用具备同版本部署的专用环境验证,未授权或未部署则标未验,不冒充 116 已验证双形态。 +4. 在 `/log` 的模型调用查询中,用真实 OBO 自然人调用与应用自身调用配对;检查候选、过滤与切范围,用无记录范围验证提示,不修补现有示例应用或历史日志。 +5. 在 `/workspace/settings/my-tasks`、`my-requests` 及 `/approval` 的测试审批详情,依次读取仅负责人、增人员/部门/组、撤销/失效;刷新后变化但版本/说明/声明/决策快照不变。用有阅读资格但无 app.manage_permission 的人员做正向摘要和反向通用 roster 验证。 +6. 本租管无创建菜单时在 `/filelib` 新建文档和 QA 库;源读取和创建菜单不足时复制仍拒绝。专用人员表单打开后迁租户或撤资格,提交不产生旧/新租户资源;普通有/无菜单及超管归属规则配对。 +7. 清理仅本轮已创建的测试对象,并记录未清理原因;不停止、迁移、重建无关实例。审批引擎其它场景仅做本次补充钩子“不调用”合同验证,不改通用 resolver,因此不跑无关全场景业务回归。 + +### 7.3 日志与发布核对 + +- manager 记录 app/exec_ref/operation/node/UID、观察分类及安全失败原因,不记录应用环境变量值。 +- app 发布摘要日志记录 instance/app/tenant、检查结果/耗时、来源数与安全 reason;失败带异常,不输出完整授权目录到日志。不得把当前摘要日志作为历史授权变更审计的替代品。 +- 知识库拒绝记录人员/预期租户/经验证所属租户及失败类型;成功继续现有创建审计,不记录会话引用的 token 或密钥。 +- 发布次序:先上能识别“新字段缺失=未知”的兼容 platform/client,再上 manager 与 backend;避免新 backend 的 null 计数被旧 UI 的 `??0` 显示成零。知识库 UI 与 Web create 增量配套,全部镜像到位才进行交付验收。无 DDL、无数据迁移;回滚代码不会重写数据,但旧 UI 不提供本次新展示,旧后端不会识别新资格。部署和回滚均须明确服务范围。 + +### 7.4 AC 追溯 + +| 规格范围 | 设计落点 | +| --- | --- | +| AC-01–AC-10 | D1、§4.2、§4.5 实例状态、§7.1 manager/backend | +| AC-11–AC-21 | D2、§4.3、§7.1 审计 | +| AC-22–AC-27 | D3/D4、§4.4 A–C、§4.5 当前摘要 | +| AC-28–AC-31 | §4.4 C–D、§4.5 available/不可确认区分与冻结保持 | +| AC-32–AC-38 | §4.4 A/D、§6.1、§7.1 三入口和反向权限验证 | +| AC-39–AC-44 | D5/D6、§4.6、§6.2、§7.1 写前资格 | +| AC-45–AC-50 | D5、§4.6 原菜单/复制/通道/模型保持 | +| AC-51、AC-52 | §2、§4.7、§6、§7.1–§7.3 | + +## 8. 不投入项与重审条件 + +- 长期监控、重启历史趋势、统一探活门槛:本轮只补当前事实;后续有明确运维需求再立项。 +- 用户姓名审计快照和历史访问者回填:现有日志没有事实就不造数据;新身份采集由独立 SDK/OBO 需求推进。 +- 超大授权范围的完整分页审批浏览:本次超过预算表达不可确认,不增加新通用目录 UI;真实遇到大规模场景再审只读分页与身份一致性。 +- 资源迁移/移交、管理员代创建到任意租户、知识库复制扩权:均未授权,不作为创建异常的兜底。 +- 通用审批 resolver、路由、outbox 与其它场景的当前授权展示:不在本次改动;若未来修改,需要按审批 skill 重新界定跨场景验证。 + +## 修订历史 + +| 日期 | 改动 | 触发原因 | +| --- | --- | --- | +| 2026-11-02 | 初版设计,52 条 AC 追溯;区分既有现状与拟实施方案 | 用户确认正式规格并要求进入设计;设计尚待单独确认 | + +| 2026-11-02 | 用户确认后按44个原子任务落地;补源版本双读、userset_relation、真实父层、Nullable运行事实与创建前重验;本地门禁和真实E2E分开记录 | 代码接线核对与定向验证;未改已确认AC或授权环境更新 | diff --git a/features/v3.0.0/115-vibe-runtime-review-fixes/discovery.md b/features/v3.0.0/115-vibe-runtime-review-fixes/discovery.md new file mode 100644 index 0000000000..3a18003fe2 --- /dev/null +++ b/features/v3.0.0/115-vibe-runtime-review-fixes/discovery.md @@ -0,0 +1,82 @@ +# F115 应用运行信息、审批可见范围与知识库创建:需求发现 + +日期:2026-11-02。阶段:用户已确认 D1–D4,进入正式规格;尚未修改业务代码。 + +- 基线:`origin/feat/3.0.0@497256d28`。 +- 分支:`codex/vibe-runtime-review-20261102`;独立工作树,主工作区保留。 +- 来源:用户本轮五项问题与六张截图;116 Docker 环境只读核对。 +- 交付约束:沿 SDD 推进,只做本功能定向验证;不迁移存量应用、不改角色绑定、不替用户修改应用代码、不运行全量回归。 + +## 1. 已核实的问题 + +| 项目 | 当前事实与原因 | 建议范围 | +| --- | --- | --- | +| 1. 健康、重启、启动时间 | 页面已有三个字段。116 的实例 API 返回 `health=null`、`restart_count=0`,缺少 `started_at`。同一容器实际 `StartedAt=2026-10-30T10:33:15.363879485Z`、重启 0 次,未配置 Docker HEALTHCHECK。v2 状态投影遗漏启动时间,并只读取 Docker Health,未用平台已有 HTTP 探活结果;K8s 投影也遗漏启动时间。 | 补齐 Docker/K8s 当前应用执行体的启动时间;健康沿已有实际探活/Ready 事实呈现,不能把 Running 或缺失数据冒充健康。真实 0 次保留为 0,未知不可显示成 0。 | +| 2. 模型调用访问用户为空 | 当前 116 Root、近 7 天查询返回 34 条记录,访问用户候选已有用户 ID 3。截图中的“应用自身”记录未保存自然人访问身份,现有候选只枚举日志中真实存在的 user/natural_person。未复现当前候选整体为空。 | 保留真实身份过滤;存在用户记录时应能选、能筛选。只有应用自身调用时明确说明无访问用户记录;查询失败、搜索无匹配与无用户记录不能混为一谈。不从应用负责人、登录人或访问时间推测历史访问者,不回填旧日志。 | +| 3. 审批可见范围为空 | 发布场景 Handler 只复制 `payload.visibility_snapshot`,发布管线未填充此字段;客户端始终读取历史 `detail_snapshot`,该字段为空就展示空文案。 | 打开审批详情时读取应用当前有效的人员、部门和用户组授权摘要;只读展示,修改授权仍走既有权限流程并立即生效。保留原审批快照,不重写历史记录。 | +| 4. 首次提交空文案 | 客户端把空数组统一翻译为“暂无可见范围信息”,没有区分“确认只有负责人”与“未知/查询失败”。 | 仅当当前权限查询成功、确认没有负责人以外的有效普通访问授权时,显示“仅负责人可见”。首发后若已新增授权,仍展示最新对象。缺失数据、失败、应用已删除不能伪装为负责人可见。 | +| 5. 租管没有新建知识库按钮 | 116 正式 `GET /tenants/60/admins` 确认 gzx01(849)为租户 60 管理员。角色菜单查询没有 create_knowledge。前端路由为租管补了 knowledge,但页面新建按钮仍只认 admin/create_knowledge;后端 create 也只认 create_knowledge 菜单,单改按钮会继续被拒绝。 | 统一前后端的本租户知识库新建资格:当前租户管理员可新建文档/QA 知识库;普通用户继续走原创建菜单。租管不能借此选择其它租户。保留模型可用性、容量等既有检查。 | + +已通过截图使用的 `120:3002` 入口再次对照:近 7 天、不限制应用时访问用户候选为 1;限定截图中的应用自身调用示例 `c117490880544bf9b695019394488478` 时候选为 0,两次均为业务码 200。先核对 120/116 鉴权公钥一致,再使用内存会话查询;未更新任一环境服务。 + +## 2. 已确认的业务边界 + +用户于 2026-11-02 明确回复“确认边界,进入正式规格”,确认以下 D1–D4;这不代替后续 spec、design 的单独确认。 + +### D1. 运行信息表示真实当前执行体 + +- Docker 使用精确绑定的当前容器;K8s 使用精确绑定的当前 Pod/应用容器,不混入初始化/维护容器。 +- 健康优先反映已有平台探活/Ready 结果,明确区分正常、异常、启动中、未知;本次不新增独立监控系统或定时探活体系。 +- 重启次数表示当前应用执行体的实际重启计数,不把版本升级、替换实例和应用历史发布次数累加为重启次数。 +- 不重建现有应用来补显示信息;时间按现有统一时间展示工具转换。 + +### D2. 模型调用保留真实身份 + +- 候选来自当前授权租户/应用/时间范围内的真实用户调用记录;过滤使用相同范围。 +- 应用以自身身份调用模型时,记录不代表某个自然人。需要按人查询的新调用,应由应用按现有 SDK/OBO 契约转发访问者身份,本次不替已有应用改码。 +- 不新增虚构人员候选,不把“用户不存在于这些记录”解释为全租户人员都可筛选;无记录提示三语交付。 + +### D3. 审批只读展示当前授权 + +- 这是对 F055 AC-24“提交时可见范围快照”的明确修订:代码、声明、版本、审批决策仍冻结;仅可见范围区改为查询时的最新授权,并标注“当前可见范围,授权变更即时生效,不影响审批”。 +- 按现有 F048 `use` 生效动作识别访问授权,涵盖直接及现有继承语义;不能按模型名字认定权限。 +- 展示人员、部门、用户组名称与类型;部门/组不展开成全部成员。服务账号不是普通浏览者,不作为人员行列出。 +- 不枚举所有因管理员身份而隐式可见的账号;这些身份继续按既有平台规则生效。 +- 当前审批阅读资格仍须校验。跨租户、失去资格、权限依赖不可用时不得泄露当前授权目录;不能借当前摘要获得应用 edit/manage_permission 或源码访问权。 +- 进行中和已进入终态的、仍有合法阅读资格的审批详情,均显示当前授权;历史快照不改写。已删除应用与不可确认权限分别提示原因。 +- 人员迁租户、停用、授权撤销与模型动作变化不能被误写成“仍可访问”;不迁移应用或授权,不修补存量历史数据。 + +### D4. 知识库创建资格 + +- 当前有效的租户管理员默认可在自己所属租户新建文档/QA 知识库,前端入口和后端资格一致;不信任前端传来的管理员标记。 +- 新资源仍归当前创建人及其所属租户,不用页面管理视角改变归属。超管与普通用户沿现有创建规则。 +- 不扩大一般部门管理员的权限,不新增角色或批量补写用户授权。 +- 本次只修新建,复制仍保留原创建菜单和源资源读取资格;页面共享条件需拆清,不能顺带放开复制。 + +## 3. 证据锚点 + +以下源码均已在固定基线 497256d28 核对,116 运行层仍使用 10 月 30 日的本机 F114 镜像;后端和前端 release 镜像已有后续更新。截图中“整个平台”不作为当前最新代码仍保留该选项的证据。 + +| 链路 | 源码位置 | +| --- | --- | +| v2 实例状态投影 | `src/runtime-manager/runtime_manager/api/platform_observe.py::status` | +| Docker 实际 HTTP 探活 | `src/runtime-manager/runtime_manager/executors/docker.py::observe` | +| K8s Ready 与应用容器重启计数 | `src/runtime-manager/runtime_manager/executors/kubernetes.py::_observed` | +| 后端实例字段白名单 | `src/backend/bisheng/app_runtime/domain/services/app_query_service.py::get_instance` | +| 发布页已有显示字段 | `src/frontend/platform/src/pages/BuildPage/hostedApp/tabs/PublishTab.tsx` | +| 审计候选/过滤 | `src/backend/bisheng/app_governance/domain/repositories/runtime_audit_repository.py::candidates / statement` | +| 审批快照占位与客户端读取 | `app_publish/.../app_publish_scenario_handler.py::build_detail`、`client/.../approval/AppPublishDetailPanel.tsx` | +| 租管前端路由补全 | `src/frontend/platform/src/routes/index.tsx::CHILD_ADMIN_ROUTE_PERMISSIONS` | +| 知识库按钮与后端创建菜单 | `KnowledgePage/KnowledgeFile.tsx`、`KnowledgeQa.tsx`、`knowledge/api/endpoints/knowledge.py::create_knowledge` | + +## 4. 定向验证计划 + +1. 状态投影:Docker 有/无原生健康检查、探活成功/失败/未知、重启为 0/正数/缺失;启动时间、执行体身份匹配;K8s 应用容器与初始化容器分离。 +2. 模型记录:自然人/OBO 与应用自身配对;候选和列表过滤一致;租户、应用、时间与无候选/搜索空/加载失败边界;不篡改历史记录。 +3. 审批范围:负责人可见、多类授权、授权变更后刷新、进行中/终态、use 与其它动作区分、继承、人员失效、跨租户与无权拒绝;当前摘要失败不影响不可变审批数据。 +4. 新建知识库:无 create_knowledge 菜单的本租管允许;无菜单普通用户拒绝、有菜单普通用户允许;跨租户拒绝、文档与 QA 一致、复制资格不扩展。 +5. 仅运行上述相关测试、变更文件静态检查与必要页面验证;真实部署/写入验收另行明确,不自动更新 116 或 120 服务。 + +## 5. 下一阶段与暂停依据 + +依据仓库 AGENTS.md §6 与 SDD-Guide §1/§3,第 3 项修改已经确认的审批快照契约,第 5 项涉及创建菜单资格的前后端一致性,采用完整 SDD。D1–D4 已获用户确认;当前进入正式 spec 编写和评审,经用户单独确认 spec 后进入设计。设计仍须单独确认,之后才能拆任务与编码。 diff --git a/features/v3.0.0/115-vibe-runtime-review-fixes/e2e-checklist.md b/features/v3.0.0/115-vibe-runtime-review-fixes/e2e-checklist.md new file mode 100644 index 0000000000..fff24ffe85 --- /dev/null +++ b/features/v3.0.0/115-vibe-runtime-review-fixes/e2e-checklist.md @@ -0,0 +1,45 @@ +# F115 真实部署 E2E 清单 + +状态:未执行。代码已实现;本轮设计确认不包含更新环境或写入真实环境的授权。依 AGENTS.md §6、已确认 design §7.2 和 e2e-test,真实验收仍是合入前门禁,不以本地 mock、脚本收集或 skipped 代替。 + +## 部署与专用数据前置 + +- [ ] 明确允许更新116的 backend/worker、platform/client 及 runtime-manager,记录精确提交、镜像摘要与部署时间。120:3002只作另一前端入口,不混同服务部署结果。 +- [ ] 准备 `e2e-f115-` 专用租户A/B、负责人、普通审批人、本租管、无创建菜单普通人;确认角色和成员,禁止复用业务人员做迁租户/停用操作。 +- [ ] 准备一个专用托管应用、真实自然人/OBO模型调用与应用自身调用记录、待审及终态审批。单独保存业务资源基线;不迁移或重建存量应用。 +- [ ] 凭据通过安全环境变量注入,不打印或落盘会话;只有显式 `F115_E2E=1` 才运行真实API。知识库创建另需 `F115_E2E_ALLOW_CREATE=1`。 +- [ ] 设置 E2E_API_BASE、F115_E2E_TENANT/FOREIGN_TENANT/APP_ID/TASK_ID/INSTANCE_ID/VISITOR_ID/TENANT_ADMIN_ID/EMBEDDING_ID;四个 TOKEN 变量为 APPROVER、FOREIGN、TENANT_ADMIN、NO_CREATE。管理视角先明确选专用租户;脚本不修改全局视角。 + +执行文件:`src/backend/test/e2e/test_e2e_f115_runtime_review.py`。导入共享 api/auth/cleanup helpers;创建前后按精确名称、owner和tenant双重清理。与功能无关的数据必须保持不变。 + +## 运行事实(AC-01, AC-02, AC-03, AC-04, AC-05, AC-06, AC-07, AC-08, AC-09, AC-10) + +- [ ] 发布页当前实例的运行/健康、真实重启0与正整数、启动时间,逐项与精确容器inspect/现有HTTP探活核对。浏览器本地时区一致,无Docker Health也可显示应用健康。 +- [ ] 仅对专用实例验证启动中、停止、无绑定、执行体丢失;读取/刷新不发发布、重建或重启。依赖不可达与明确探活失败不同;未知不得为0或历史时间。 +- [ ] Docker实测并留证;K8s只有另行授权且部署同版本的专用环境才验收,检查app容器与UID、Ready/EndpointSlice,不把Docker本地测试当作K8s部署证据。 + +## 模型访问用户(AC-11, AC-12, AC-13, AC-14, AC-15, AC-16, AC-17, AC-18, AC-19, AC-20, AC-21) + +- [ ] 自然人和应用自身混合记录:真实人员候选非空,按人员选择只返回匹配记录;应用和服务账号自身不伪装成负责人或访问者。 +- [ ] 无自然人记录、无匹配搜索、加载中、请求失败/重试分别有正确文案;不回填旧日志。 +- [ ] 切租户、应用、时间和调用密钥时候选/记录重新查询,旧人员选择失效;其它租户候选和旧异步响应不残留。 + +## 审批当前范围(AC-22, AC-23, AC-24, AC-25, AC-26, AC-27, AC-28, AC-29, AC-30, AC-31, AC-32, AC-33, AC-34, AC-35, AC-36, AC-37, AC-38) + +- [ ] 真实普通审批人无manage_permission仍能在原任务阅读资格内查看摘要;审批外授权列表和写入保持拒绝。无阅读资格及跨租户读取为18101,正文不泄露名单。 +- [ ] 人员、部门/下级、组成员/管理员按真实当前use来源展示,类型及ID可区分同名对象;服务账号不列入,管理员隐式访问有说明。 +- [ ] 未配置use的owner/manager模型不算访问;撤权、人员停用/迁租户、模型动作变更后刷新不再表达有效。仅负责人有资格且没有其它普通对象时才显示“仅负责人可见”。 +- [ ] 首发已共享、首发仅负责人、负责人失效且无人、投影/目录失败、结果超预算、应用删除分别验证;失败不显示部分名单或owner-only。 +- [ ] 我的审批、我的申请、管理异常详情以及终态审批显示一致;重复展开和刷新重查。冻结版本、发布说明、档位、能力、审批结果与原snapshot逐项一致,刷新不写数据。 + +## 知识库(AC-39, AC-40, AC-41, AC-42, AC-43, AC-44, AC-45, AC-46, AC-47, AC-48, AC-49, AC-50) + +- [ ] 无创建菜单的本租管在文档/QA均能新建;普通无菜单、仅部门管理员拒绝,普通有菜单保持允许;服务器忽略伪造人员/租户身份。 +- [ ] 打开表单后在专用人员上分别撤租管/停用/迁租户:提交前再验;所属变化10993,不能写旧租户或静默换目标。无leaf身份时前端不提交。 +- [ ] 超管切其它管理租户,新建仍归真实自然人所属租户;复制仍需原菜单与源读取。原模型、名称、租户状态、投影失败补偿保持。 +- [ ] 创建与GET落库事实一致;清理只涉及专用测试名称/人员/租户。任一清理归属不符立即停止,不扩大删除范围。 + +## 三语与保护范围(AC-51, AC-52) + +- [ ] 简中/英/日各看一次新增提示;未知、真实0、无记录、失败、owner-only含义可区分。 +- [ ] 比较业务存量资源、审批snapshot与授权基线,确认无迁移、历史回写、自动补授权或无关服务变化。 diff --git a/features/v3.0.0/115-vibe-runtime-review-fixes/spec-review.md b/features/v3.0.0/115-vibe-runtime-review-fixes/spec-review.md new file mode 100644 index 0000000000..519c2f38f3 --- /dev/null +++ b/features/v3.0.0/115-vibe-runtime-review-fixes/spec-review.md @@ -0,0 +1,48 @@ +# F115 正式规格自审记录 + +日期:2026-11-02。对象:`spec.md`,AC-01 至 AC-52,共 52 条。审查方式:同一代理按 `sdd-review` 的 spec 清单自审,非独立代理复审。 + +阶段更新:用户于 2026-11-02 回复“确认规格,进入设计”,正式规格已确认;下文保留当次自审与当时暂停点。 + +## 结论 + +LGTM + +此结论仅表示当前规格通过静态自审,仍须用户单独确认正式规格;不表示设计已确认、业务代码已完成或真实环境已通过验证。 + +## 审查依据 + +- 已获用户确认的 `discovery.md` D1–D4 与五项原始问题。 +- 应用工场 PRD-1 的 RT-01、RT-03、GOV-01、GOV-04;本轮 D3 是用户明确采纳的可见范围展示合同修订,D4 是明确采纳的创建资格增量。 +- `features/_templates/spec.md`、`docs/SDD-Guide.md`、`docs/constitution.md`、v3.0.0 `release-contract.md`。 +- F055 AC-24、F114 AC-33 及既有审批/租户资格;后端领域与权限架构说明。 +- `.claude/skills/sdd-review/references/spec-checklist.md` 的实际 10 项检查。技能入口中的“14 项”及“spec 合并设计”说明与现行清单、模板和 SDD Guide 不一致,本次按现行清单与纯 What 口径执行,没有冒记额外检查。 + +## 清单结果 + +| # | 检查项 | 结果 | 核对证据 | +| --- | --- | --- | --- | +| 1 | 功能与场景覆盖 | PASS | D1→AC-01–AC-10;D2→AC-11–AC-21;D3→AC-22–AC-38;D4→AC-39–AC-50;共同约束→AC-51、AC-52,五项问题均有对应 | +| 2 | 边界与错误场景 | PASS | 运行未知/失败/替换、真实零值、混合调用身份、搜索/加载失败、失效对象、终态审批、删除与不完整授权、新建资格变化及复制边界均有可观测结果 | +| 3 | 明确排除项 | PASS | 不新建监控体系、不猜测身份、不改历史快照、不改已有应用源码、不迁移资源、不批量补授权、不扩展复制与其它角色资格 | +| 4 | 唯一且可追溯 AC | PASS | 52 个定义,连续且唯一为 AC-01–AC-52;范围表、边界表和合同关联均引用本规格已定义编号 | +| 5 | 可测试性 | PASS | 零值/未知、合法范围、真实身份、只读摘要、可见对象、创建拒绝均有可观察条件与结果;无“响应快”等不可测要求 | +| 6 | EARS 句型 | PASS | 每条 AC 使用 WHEN / WHILE / IF / WHERE 与 THE SYSTEM SHALL;条件变化、异常拒绝与正常结果分明 | +| 7 | 错误码边界 | PASS | 规格只要求可观测错误含义,未分配错误码或维护实现错误码表 | +| 8 | 领域归属 | PASS | 只协调既有运行信息、审计、发布场景及知识库创建资格;版本契约表与增量记录同步登记 F115,没有取得权限/审批/组织对象所有权 | +| 9 | 不变量与架构铁律 | PASS | 保持可信身份、租户隔离、双形态一致、发布必审、资产持久;不要求直接访问权限引擎或编排器。F055/F114 两处既有合同修订已明示,不伪装为原规则不变 | +| 10 | 纯 What | PASS | 不含接口形态、数据模型、分层接线、实现文件清单或性能指标;仅定义用户结果与权限边界,设计尚未开始 | + +## 本轮自审补齐 + +1. 显式登记 F114 AC-33 的限定审批摘要例外,防止将“审批人可读”误扩展为通用授权目录读取资格。 +2. 明确“当前授权范围”与“此刻运行可达”不同:草稿、待审批和下线本身不清空已生效授权,否则首发审批永远无法准确表达范围。 +3. 明确负责人已失效且无其它有效普通授权时的空态;完整查询失败或部分结果不能宣称“仅负责人可见”。 + +以上是对已确认 D3 边界的明晰化,不新增资源移交、授权写入或发布行为。 + +## 验证范围与暂停点 + +- 仅核对 Markdown 结构、AC 唯一与引用、链接、HTML 导航及源文一致性;本轮没有业务代码变更,不运行业务测试或全量回归。 +- D1–D4 已于 2026-11-02 获用户确认;正式 spec 尚待确认,依据 AGENTS.md §6、SDD-Guide §3 停在 spec 确认点。 +- 用户确认后进入 design,并在设计中说明真实身份候选、当前授权查询边界、运行事实及知识库创建资格的具体实现与定向验收。 diff --git a/features/v3.0.0/115-vibe-runtime-review-fixes/spec.md b/features/v3.0.0/115-vibe-runtime-review-fixes/spec.md new file mode 100644 index 0000000000..d371988dc3 --- /dev/null +++ b/features/v3.0.0/115-vibe-runtime-review-fixes/spec.md @@ -0,0 +1,159 @@ +# F115:应用运行信息、审批当前可见范围与知识库创建 + +> **本文档定位:纯 What。** 只定义需求、验收标准与边界;实现选择、接口、数据模型及文件变更在设计阶段确定。 + +**状态**:用户于 2026-11-02 明确确认正式规格,进入设计;需求发现 D1–D4 同日已确认,尚未进行业务编码。 + +**优先级**:P0(权限边界与错误信息一致性)。\ +**所属版本**:v3.0.0;发版线 `feat/3.0.0`。\ +**编写基线**:`origin/feat/3.0.0@497256d289793ce016df07cf8ab38f32988faca4`。此基线及只读排查记录不代表 F115 已实现或环境已更新。\ +**关联 PRD**:[本轮已确认需求发现](./discovery.md) D1–D4(直接需求来源);[应用工场 PRD-1](../../../docs/PRD/3.0-release/3.0%20应用工场%20PRD-1%20专业开发者通道与应用运行时.md) RT-01、RT-03、GOV-01、GOV-04(既有产品背景)。\ +**依赖**:F048 权限动作;F054 应用与运行状态;F055 发布审批;F056 运行审计查询;F059/F086 运行形态;F113 身份与租户资格;F114 权限映射及具体租户视角。审批引擎、知识库创建与组织身份继续由既有领域负责。 + +## 范围 + +**本次纳入**: + +- 发布页显示当前执行体的真实健康状态、实际重启次数与启动时间,区分未知和真实零值。 +- 模型调用的访问用户候选与筛选保持真实、范围一致,明确无用户记录、搜索无匹配、加载与失败状态。 +- 应用发布审批只读展示当前有效的人员、部门、用户组访问授权;能确认时显示“仅负责人可见”。 +- 当前有效的租户管理员可在所属租户新建文档和 QA 知识库,入口与实际创建资格一致。 + +**本次明确排除**: + +- 不建设新的监控系统,不扩大探活体系,不为补显示而重启或重建现有应用。 +- 不猜测、回填模型调用的历史访问身份,不替现有应用改码转发访问者身份。 +- 不修改审批决定、版本内容或历史快照,不把可见范围变更变成发布审批事项。 +- 不迁移、移交应用或知识库,不批量补授权,不更改已有角色、部门或用户组绑定。 +- 不新增权限动作,不扩大普通用户、一般部门管理员或知识库复制资格。 +- 不扩展源码审读、审批预览、模型调用或其它平台能力的授权。 + +## 1. 用户故事 + +1. 作为应用管理者,我希望看到当前实例实际的健康、重启和启动情况,以便判断运行是否正常,并识别信息暂不可用。 +2. 作为有审计资格的管理员,我希望按真实访问用户筛选模型调用,并知道候选为空的原因,以便准确定位调用而不误判应用自身调用。 +3. 作为有权阅读应用发布审批的人,我希望看到应用当前已授权的人员、部门和用户组,以便理解当前访问范围,且不把它误认为冻结的发布内容。 +4. 作为首次发布的负责人或审批阅读者,我希望在确实只有负责人可见时看到明确说明,以便知道是否还需要设置可见范围。 +5. 作为租户管理员,我希望无需额外创建菜单授权即可在所属租户新建文档或 QA 知识库,以便履行本租户管理职责。 + +### 术语 + +- **当前可见范围**:当前有效的普通访问授权对象,按既有 `use` 动作及身份、租户规则判定;不等同于应用此刻已经上线、可以建立运行连接。草稿、待审批或已下线本身不应清空其有效授权范围。 +- **负责人**:应用当前记录的资源归属自然人;是否仍具有效访问资格应另行确认,不因名字仍在审批单上而推定有效。 +- **租户管理员**:具备当前有效租户管理资格的自然人;不同于只有部门管理资格的人员。 + +## 2. 验收标准 + +### 2.1 当前执行体的运行信息(D1) + +- **AC-01** — WHEN 有权查看运行状态的用户打开或刷新应用发布页,THE SYSTEM SHALL 仅以该应用当前精确绑定的执行体展示运行信息,不混入其它应用、旧实例、审批预览或维护执行体的信息。 +- **AC-02** — WHEN 当前执行体已有可用的应用探活或就绪结果,THE SYSTEM SHALL 按真实结果展示健康正常或异常;Docker 未配置原生健康检查不能成为忽略已有平台应用探活结果的理由。 +- **AC-03** — WHILE 当前执行体仍在启动且尚无已完成的健康判定,THE SYSTEM SHALL 表达启动中;IF 无法确认当前健康,THEN THE SYSTEM SHALL 表达未知,不以“进程运行中”或缺失结果推断健康正常。 +- **AC-04** — IF 运行信息查询失败或观测依赖不可用,THEN THE SYSTEM SHALL 明确表示相关信息暂不可确认并允许刷新;仍可独立确认的信息保持可读,不把查询故障伪装成应用健康、零次重启或不存在。 +- **AC-05** — WHEN 显示重启次数,THE SYSTEM SHALL 使用当前执行体中应用容器的真实重启计数,不累加应用历史发布次数、版本升级次数或被替换执行体的计数,也不混入初始化或维护容器的计数。 +- **AC-06** — WHEN 可确认重启次数为零或正整数,THE SYSTEM SHALL 如实显示该值;IF 无法取得或确认计数,THEN THE SYSTEM SHALL 显示未知,不以零代替。 +- **AC-07** — WHEN 可取得当前应用容器的实际启动时间,THE SYSTEM SHALL 展示该时间,不用审批时间、发布时间、Pod 创建时间或初始化容器的启动时间代替。 +- **AC-08** — WHEN 展示有效启动时间,THE SYSTEM SHALL 遵循平台已有的统一时区展示口径;IF 时间缺失、无效或未实际启动,THEN THE SYSTEM SHALL 显示未知或尚未启动,不显示虚构日期。 +- **AC-09** — WHEN 应用停止、执行体不存在或已被替换,THE SYSTEM SHALL 区分这些状态,且不得把已失效执行体的健康、重启或启动信息继续标为当前实例信息;刷新后应反映当前绑定。 +- **AC-10** — WHERE 应用运行于 Docker 或 K8s,THE SYSTEM SHALL 对上述状态采用一致的产品含义,且读取或刷新运行信息不得触发发布、重启、重建或资产变更。 + +### 2.2 模型调用访问用户候选与过滤(D2) + +- **AC-11** — WHEN 查询模型调用的访问用户候选,THE SYSTEM SHALL 将候选限定在调用者当前有权查询的具体租户、应用、时间及其它已选筛选条件内,与相应记录查询的范围一致。 +- **AC-12** — WHEN 记录中存在经平台验证的自然人执行身份,THE SYSTEM SHALL 允许该真实身份成为访问用户候选;不得依据应用负责人、当前登录人、调用时间接近或应用标识推测访问用户。 +- **AC-13** — WHEN 模型调用仅以应用自身或服务账号自身身份执行,THE SYSTEM SHALL 不把该调用者冒充为自然人访问用户;应用缺少访问者身份不等于数据查询失败。 +- **AC-14** — WHEN 当前查询范围存在自然人访问记录,即使同时存在应用自身调用,THE SYSTEM SHALL 提供对应自然人候选,并允许选择和按该用户筛选;不得因混合身份记录而使整个候选列表为空。 +- **AC-15** — WHEN 展示访问用户候选,THE SYSTEM SHALL 保留能够对应记录的真实身份标识;不得混入无相关调用记录的全租户人员,或用负责人等其它账号替换已不存在或不可确认的历史身份。 +- **AC-16** — WHEN 选择访问用户并查询记录,THE SYSTEM SHALL 仅返回该真实身份与其它已选条件同时匹配的模型记录,不能将同一应用的其它用户或应用自身调用归给该用户。 +- **AC-17** — IF 查询成功且当前范围没有自然人访问记录,THEN THE SYSTEM SHALL 明确提示当前范围没有访问用户记录,并说明应用自身调用不代表自然人访问,不仅显示无含义的空白。 +- **AC-18** — IF 访问用户搜索成功但没有匹配候选,THEN THE SYSTEM SHALL 提示没有匹配用户,区别于整个范围没有自然人记录;清除搜索后可恢复当前范围的真实候选。 +- **AC-19** — WHILE 候选正在加载,THE SYSTEM SHALL 表达加载状态;IF 加载失败,THEN THE SYSTEM SHALL 表达失败并允许重试,不将失败展示成“没有访问用户记录”或“没有匹配用户”。 +- **AC-20** — WHEN 具体租户、应用、时间或相关查询条件改变,THE SYSTEM SHALL 使候选及筛选结果对应新范围,不保留或泄露旧范围的候选与结果;不再适用的已选身份不得导致界面悄悄查询旧范围。 +- **AC-21** — WHEN 提供上述查询与提示,THE SYSTEM SHALL 保留已有可信访问者与 SDK/OBO 身份规则,不回填或改写历史记录;需要按人记录的新调用仍须带有依既有规则验证的访问者身份。 + +### 2.3 审批中的当前可见范围(D3) + +- **AC-22** — WHEN 有合法阅读资格的用户打开或刷新应用发布审批详情,THE SYSTEM SHALL 展示查询时应用当前有效的可见范围,不继续把提交时的空占位或旧授权快照当作当前范围。 +- **AC-23** — WHEN 识别当前可见范围,THE SYSTEM SHALL 以现有 F048 `use` 生效动作和既有身份、租户条件为准,涵盖直接授权及现有继承语义,包括归属人的有效所有者访问资格;草稿、待审批或已下线本身不应清空有效授权范围,也不表示应用已经可以运行访问。 +- **AC-24** — WHEN 存在有效的普通访问授权对象,THE SYSTEM SHALL 按人员、部门、用户组分别展示对象名称及类型,能区分同名不同类型对象;部门和用户组不展开成全部成员。 +- **AC-25** — WHEN 展示普通访问授权对象,THE SYSTEM SHALL 排除服务账号,并说明管理员仍按平台既有规则访问,不把所有因管理员身份而隐式可见的账号逐一列作普通人员授权。 +- **AC-26** — IF 某对象只有其它管理动作而没有生效的 `use`,THEN THE SYSTEM SHALL 不将其认作当前访问授权对象;不能单凭“所有者”“管理者”等权限模型名称判定可见。 +- **AC-27** — WHEN 授权被撤销、相关人员停用或迁离租户,或权限模型动作发生变化,THE SYSTEM SHALL 在后续打开或刷新时按现有有效资格反映变化,不把已失效对象继续表达为可以访问;部门或用户组的展示不承诺其每一名成员都已获得访问资格。 +- **AC-28** — IF 当前范围查询成功且完整、负责人当前具备有效访问资格,且没有负责人以外的有效普通访问授权对象,THEN THE SYSTEM SHALL 明确显示“仅负责人可见”;此文案不否定 AC-25 所述管理员隐式访问规则。IF 完整查询确认负责人也没有有效访问资格且无其它有效普通访问授权对象,THEN THE SYSTEM SHALL 表达当前没有有效普通访问对象,而非“仅负责人可见”。 +- **AC-29** — WHEN 首次发布的审批详情被打开,THE SYSTEM SHALL 依据当前有效范围决定显示“仅负责人可见”或具体授权对象;首次发布本身、历史快照为空、负责人失效或当前查询失败均不能单独作为“仅负责人可见”的依据。 +- **AC-30** — WHEN 显示该区域,THE SYSTEM SHALL 明确标注“当前可见范围,授权变更即时生效,不影响审批”的含义;变更可见范围仍走既有授权流程,不创建新发布审批、不重置现有审批进度,也不要求重新提交版本。 +- **AC-31** — WHEN 读取当前可见范围,THE SYSTEM SHALL 保持原审批快照和历史记录不变;代码、版本、能力声明、资源档位、发布说明及审批决定继续遵守各自原有冻结规则,不能因刷新可见范围被替换成应用最新内容。 +- **AC-32** — WHERE 应用发布审批处于进行中或已经进入通过、驳回、撤回、取消等终态,THE SYSTEM SHALL 对仍具合法阅读资格的用户提供相同含义的当前可见范围;进入终态本身不新增阅读资格。 +- **AC-33** — WHEN 用户具备该次审批的合法阅读资格,THE SYSTEM SHALL 允许其只读查看该审批所对应应用的当前人员、部门、用户组访问授权摘要,不额外要求长期应用 `manage_permission`;该例外只用于当前审批展示,不成为任意查询应用授权目录的资格。 +- **AC-34** — WHEN 用户通过 AC-33 查看当前摘要,THE SYSTEM SHALL 不因此授予授权增删、权限模型选择、应用管理、源码审读或预览资格;审批之外的完整授权主体查询及授权变更仍须遵守 F114 的 `manage_permission` 规则,原有审批审读与预览资格另行判定。 +- **AC-35** — IF 用户没有该审批的阅读资格、已失去资格,或请求违反现有租户隔离,THEN THE SYSTEM SHALL 拒绝读取当前可见范围,且不泄露授权对象信息;知道审批或应用标识不能代替资格。 +- **AC-36** — IF 对应应用已删除,THEN THE SYSTEM SHALL 明确表示无法读取当前可见范围的原因,不显示“仅负责人可见”,也不恢复应用、授权或审批;其它依法可读的冻结审批内容保持可读。 +- **AC-37** — IF 不能完整确认当前授权或对象资格,THEN THE SYSTEM SHALL 明确表示当前可见范围暂不可确认,并允许重新查询;不得把失败、部分结果或缺失信息当作完整名单、无人可见或“仅负责人可见”,其它依法可读的冻结审批内容仍保持可读。 +- **AC-38** — WHERE 既有“我的审批”“我的申请”或审批管理详情允许阅读该应用发布审批,THE SYSTEM SHALL 保持上述当前范围含义一致;本次不新增审批管理入口或异常处理资格。 + +### 2.4 所属租户的知识库新建资格(D4) + +- **AC-39** — WHEN 当前有效的租户管理员进入文档知识库页面,THE SYSTEM SHALL 提供新建入口,并允许其在自己所属租户创建文档知识库,不要求另有创建知识库菜单授权。 +- **AC-40** — WHEN 当前有效的租户管理员进入 QA 知识库页面或提交新建,THE SYSTEM SHALL 提供与文档知识库一致的本租户新建资格,不因知识库类型不同而缺失入口或被创建菜单拒绝。 +- **AC-41** — WHEN 判断是否可以新建知识库,THE SYSTEM SHALL 使页面入口与实际创建资格一致,并以服务端确认的当前身份和所属租户判定;仅修改界面、伪造管理员标记或绕过页面直接请求均不能获得额外资格。 +- **AC-42** — WHEN 按上述资格创建知识库,THE SYSTEM SHALL 保持资源归属为实际创建自然人及其所属租户,不把页面管理视角、选中的其它租户或服务账号改写为创建人和资源租户。 +- **AC-43** — IF 租户管理员试图在所属租户以外创建知识库,THEN THE SYSTEM SHALL 拒绝,不允许通过篡改租户信息或沿用其它租户管理页面完成创建。 +- **AC-44** — IF 打开新建表单后,人员被停用、租户管理资格被撤销或所属租户变化,THEN THE SYSTEM SHALL 在实际提交时重新按当前有效资格判定;不再有创建资格或表单目标不再属于当前所属租户时拒绝,不能写入旧租户或静默改写到其它租户。 +- **AC-45** — WHEN 无创建菜单且不具租户管理员或其它既有创建资格的普通用户尝试新建,THE SYSTEM SHALL 保持拒绝,不因本次修复取得创建资格。 +- **AC-46** — WHEN 普通用户已有有效创建菜单授权,THE SYSTEM SHALL 保留其原有新建资格及其它既有校验,不要求该用户同时成为租户管理员。 +- **AC-47** — WHEN 平台超管新建知识库,THE SYSTEM SHALL 保持其既有创建资格与归属规则,不把管理视角切换改为创建到所选租户的能力。 +- **AC-48** — WHEN 用户只有一般部门管理员身份,THE SYSTEM SHALL 不因该身份获得本次新增的租户管理员创建资格;其其它合法创建途径不受影响。 +- **AC-49** — WHEN 用户复制知识库,THE SYSTEM SHALL 继续要求原有创建菜单资格与源资源的读取资格;本次本租户新建例外不自动放开复制、源资源读取或其它资源动作。 +- **AC-50** — WHEN 租户管理员按新增资格提交新建,THE SYSTEM SHALL 继续遵守已有模型可用性、容量、名称、租户状态等创建条件;不得通过新增角色、补写个人菜单或批量授权来改变已有账号配置。 + +### 2.5 共同约束 + +- **AC-51** — WHEN 展示本功能新增或修改的状态、提示及范围说明,THE SYSTEM SHALL 在简体中文、英文和日文中提供含义一致的文案,保留真实零值、未知、无记录、查询失败及“仅负责人可见”的区别。 +- **AC-52** — WHEN 使用上述运行信息、审计查询、审批摘要或新建资格,THE SYSTEM SHALL 保持现有身份有效性、具体租户视角及资源权限边界,不迁移存量资源、改写历史记录或自动补授权;本规格的明确例外仅为 AC-33 的审批只读摘要与 AC-39、AC-40 的本租户知识库新建。 + +## 3. 边界情况 + +| 场景 | 期望的可观测结果 | 验收关联 | +| --- | --- | --- | +| Docker 没有原生健康检查,但已有平台探活事实 | 使用真实应用探活结果;没有可确认结果才表达未知 | AC-02、AC-03 | +| 进程运行但探活失败、执行体正在启动、观测依赖失败 | 分别表达异常、启动中、暂不可确认,不能统称健康 | AC-02–AC-04 | +| 重启计数为零、缺失;应用容器与初始化容器时间不同 | 零与未知不同;只显示当前应用容器事实 | AC-05–AC-08 | +| 停止、删除执行体、版本替换后刷新 | 不把旧执行体信息当作当前值;读取没有生命周期副作用 | AC-09、AC-10 | +| 历史记录只有应用自身身份,或与自然人记录混合 | 前者明确无访问用户;后者保留真实用户候选 | AC-12–AC-17 | +| 搜索无匹配、加载失败、具体租户切换 | 三者不能混为无记录;重试与新范围不泄露旧信息 | AC-18–AC-20 | +| 首发审批之后授权增加、减少,或人员迁租户/停用 | 刷新显示当前有效对象;不回写审批快照 | AC-22–AC-32 | +| 审批阅读者没有长期管理授权 | 只读当前摘要;不因此获得授权管理、源码或预览权限 | AC-33、AC-34 | +| 已无任何有效普通访问对象,且负责人也失效 | 不能显示“仅负责人可见”;完整查询可表达当前无有效普通访问对象,并与依赖失败区分 | AC-23、AC-27–AC-29、AC-37 | +| 应用删除、权限或组织信息不能完整确认 | 明确原因,不显示误导性的负责人可见或完整名单;保留合法冻结内容 | AC-35–AC-37 | +| 租管无创建菜单,或表单打开后资格变化 | 前者可在所属租户新建;后者重新校验并按当前资格拒绝不再合法的提交 | AC-39–AC-44 | +| 复制知识库、一般部门管理员、普通有/无菜单用户 | 本次不扩大复制或部门管理资格,普通用户沿原创建规则 | AC-45–AC-50 | + +## 4. 与既有规格的关系 + +本节登记需求增量,不将旧规格原文或旧验收证据改写为本功能已交付。 + +| 既有合同 | 本规格关系 | +| --- | --- | +| PRD-1 RT-03 审批人侧“可见范围:提交时快照” | 依本轮已确认 D3,按本规格当前授权展示口径修订;仅此展示项发生变化,不把发布其它内容改为动态 | +| F054 运行状态;F059/F086 双形态 | AC-01–AC-10 补齐当前事实与未知语义;不改变运行控制协议或生命周期权限 | +| F056 审计查询;F051 调用身份事实 | AC-11–AC-21 明确真实身份与空态,不新增自然人身份来源、不改历史账本 | +| **F055 AC-24 的可见范围快照部分** | **由 AC-22–AC-32、AC-36、AC-37 修订为查询时的当前授权展示**;其余冻结审读与审批行为保持 | +| **F114 AC-33 的完整授权主体查看限制** | **追加 AC-33 的限定审批只读例外**;其它授权目录查询、授权编辑仍按 AC-34 和原 `manage_permission` 合同 | +| F114 审批任务/管理资格及 F113 租户边界 | AC-32–AC-38、AC-52 保留原资格;当前摘要不产生新的审批、管理或跨租户能力 | +| 知识库既有新建菜单资格 | AC-39、AC-40 追加当前租户管理员的所属租户新建资格;AC-42–AC-50 保留归属、其它校验及复制规则 | +| 版本领域归属及 INV | 不新增独立领域对象或权限动作,不修改不变量;只协调原有领域需求增量 | + +## 5. 设计与实施 + +正式规格已获用户单独确认,当前进入设计;任务拆解与业务编码尚未开始。设计再单独确认后进入任务与开发。接口、错误码、数据流、实现文件及定向验证步骤在相应阶段定义。 + +验证边界沿已确认发现:只做本功能必要的定向验证,不跑全量测试;真实环境写入验收和服务更新不由本次规格确认自动授权。 + +## 相关文档 + +- [已确认需求发现](./discovery.md) +- [版本契约](../release-contract.md) +- [F055 原发布规格](../055-app-publish-pipeline/spec.md) +- [F114 已确认权限规格](../114-vibe-permission-actions/spec.md) +- [架构铁律](../../../docs/constitution.md) +- [SDD 流程](../../../docs/SDD-Guide.md) diff --git a/features/v3.0.0/115-vibe-runtime-review-fixes/tasks.md b/features/v3.0.0/115-vibe-runtime-review-fixes/tasks.md new file mode 100644 index 0000000000..ddb13b8e7b --- /dev/null +++ b/features/v3.0.0/115-vibe-runtime-review-fixes/tasks.md @@ -0,0 +1,407 @@ +# Tasks:F115 应用运行信息、审批当前可见范围与知识库创建 + +**关联**:[spec.md](./spec.md) · [design.md](./design.md)。版本 v3.0.0。 + +## 状态 + +|步骤|状态|备注| +|---|---|---| +|discovery|✅ 已确认|2026-11-02,D1–D4| +|spec.md|✅ 已评审|用户单独确认,52 条 AC| +|design.md|✅ 已评审|用户单独确认后进入任务与开发| +|tasks.md|✅ 已拆解|44 项,21 项清单自审,依赖和 AC 静态校验| +|实现|进行中|44 / 44 代码与本地验证任务完成;真实 E2E 尚未执行;只做本功能定向验证| +|真实 E2E|未执行|服务更新与写入未由本轮设计确认自动授权| + +## 开发约束 + +按以下依赖顺序单代理实施;测试先于配对后端实现,测试任务只写测试。无新表/DDL,无 Worker 改动,无数据迁移;原创建补偿保持。共享文件只修改本功能触及段,不扩展复制或其它审批场景。技能与命名空间实际接线校验若发现仅文件路径差异,在此修正元数据并同步设计,不改变已确认业务边界。每个实现任务完成执行 task-review,最后按 sdd-code-review 对固定基线审查。真实 E2E 待明确环境授权后执行,不虚报通过。 + +## Tasks + +### T001 · 基础设施 + +- [x] **T001**:Web 新建前置 DTO 与错误码 + +**文件**:`src/backend/bisheng/common/errcode/knowledge.py`;`src/backend/bisheng/knowledge/domain/schemas/knowledge_create_schema.py`。\ +**逻辑/上下文**:定义未占用10993及仅Web使用的expected_tenant_id请求,原KnowledgeCreate不扩展目标租户。\ +**依赖**:无。\ + +### T002 · 基础设施 + +- [x] **T002**:业务错误三语(中文、英文) + +**文件**:`src/frontend/packages/locales/src/api_errors/zh-Hans.json`;`src/frontend/packages/locales/src/api_errors/en.json`。\ +**逻辑/上下文**:增加10993所属租户已变化文案,不改已有错误码。\ +**依赖**:T001。\ + +### T003 · 基础设施 + +- [x] **T003**:业务错误日文 + +**文件**:`src/frontend/packages/locales/src/api_errors/ja.json`。\ +**逻辑/上下文**:增加10993日文;共享locale源为准。\ +**依赖**:T001。\ + +### T044 · 基础设施 + +- [x] **T044**:E2E精确清理护栏 + +**文件**:`src/backend/test/e2e/helpers/cleanup.py`;`src/backend/test/knowledge/test_f115_e2e_cleanup.py`。\ +**逻辑/上下文**:共享helper只清理e2e-f115精确名称且当前owner/tenant一致的库,跨租户/非测试名称先拒绝;无新增业务能力。\ +**依赖**:无。\ + +### T004 · 测试 + +- [x] **T004**:v2运行观测配对测试 + +**文件**:`src/runtime-manager/tests/test_f115_observation.py`;`src/runtime-manager/tests/test_k8s_observe.py`。\ +**逻辑/上下文**:精确绑定、HTTP事实、0/null、启动、停止、缺失和替换,mock只读平台/容器;拒绝写操作。\ +**依赖**:无。\ +**覆盖 AC**: AC-01, AC-02, AC-03, AC-04, AC-05, AC-06, AC-07, AC-08, AC-09, AC-10 + +### T005 · 后端 Domain + +- [x] **T005**:执行器观察事实 + +**文件**:`src/runtime-manager/runtime_manager/executors/docker.py`;`src/runtime-manager/runtime_manager/executors/kubernetes.py`。\ +**逻辑/上下文**:observe添加nullable当前应用容器事实及probe_state,保留原生命周期ready规则,不混init/旧容器。\ +**依赖**:T004。\ + +### T006 · 后端 API + +- [x] **T006**:v2只读状态归一化 + +**文件**:`src/runtime-manager/runtime_manager/api/platform_observe.py`;`src/runtime-manager/runtime_manager/observation.py`。\ +**逻辑/上下文**:统一观察字段及绑定前后校验;无绑定/执行体丢失分开,观测失败不可用;不回到v1。\ +**依赖**:T005。\ + +### T007 · 测试 + +- [x] **T007**:后端当前实例字段测试 + +**文件**:`src/backend/test/app_runtime/test_f115_instance_observation.py`。\ +**逻辑/上下文**:mock原可读资源与编排器,证明字段白名单/nullable和16121,404不回退旧实例。\ +**依赖**:无。\ +**覆盖 AC**: AC-01, AC-04, AC-06, AC-08, AC-09, AC-10, AC-52 + +### T008 · 后端 Domain + +- [x] **T008**:实例响应去掉历史事实回退 + +**文件**:`src/backend/bisheng/app_runtime/domain/services/app_query_service.py`。\ +**逻辑/上下文**:增加observation_state,只转发真实字段,旧模糊404报无法确认,保留原状态阅读门禁。\ +**依赖**:T006,T007。\ + +### T009 · 测试 + +- [x] **T009**:当前人员与Web创建身份 + +**文件**:`src/backend/test/user/test_f115_current_identity.py`。\ +**逻辑/上下文**:人员启停、唯一成员、迁租户、JWT版本/期限和关闭多租户,无额外成员行不拒绝有效单租户。\ +**依赖**:无。\ +**覆盖 AC**: AC-27, AC-28, AC-29, AC-35, AC-39, AC-40, AC-41, AC-42, AC-43, AC-44, AC-47, AC-52 + +### T010 · 后端 Domain + +- [x] **T010**:人员事实和新建身份薄入口 + +**文件**:`src/backend/bisheng/user/domain/repositories/runtime_identity.py`;`src/backend/bisheng/user/application/runtime_identity.py`。\ +**逻辑/上下文**:新增批量当前人员事实和Web创建用途身份,原runtime/preview入口严格语义不改;SQL只在仓储。\ +**依赖**:T009。\ + +### T011 · 测试 + +- [x] **T011**:业务主体目录资格 + +**文件**:`src/backend/test/tenant/test_f115_visibility_subjects.py`。\ +**逻辑/上下文**:同租户有效人员/部门/组与停用迁离/歧义/目录失败分离,不展开成员,名称完整才能确认。\ +**依赖**:T010。\ +**覆盖 AC**: AC-23, AC-24, AC-25, AC-27, AC-28, AC-29, AC-37, AC-52 + +### T012 · 后端 Domain + +- [x] **T012**:主体批量只读解析 + +**文件**:`src/backend/bisheng/tenant/domain/repositories/permission_subject_repository.py`;`src/backend/bisheng/tenant/domain/services/f048_permission_subject.py`。\ +**逻辑/上下文**:批量解析有效/失效/不可确认并保留userset;通过user应用读取人员事实,不改授权写入。\ +**依赖**:T011。\ + +### T013 · 测试 + +- [x] **T013**:F048 action 来源完整性 + +**文件**:`src/backend/test/permission/test_f115_action_sources.py`。\ +**逻辑/上下文**:action而非名称/level、CUSTOM/INHERIT、CURRENT、分页推进/预算、Catalog或版本变化fail closed。\ +**依赖**:无。\ +**覆盖 AC**: AC-23, AC-26, AC-27, AC-28, AC-33, AC-34, AC-37, AC-52 + +### T041 · 后端 Domain + +- [x] **T041**:来源一致性仓储读取 + +**文件**:`src/backend/bisheng/permission/domain/repositories/grant_repository.py`。\ +**逻辑/上下文**:精确资源及严格租户内读取最多5001条Grant,用于前后版本复核;不改授权写入。\ +**依赖**:T013。\ + +### T014 · 后端 Domain + +- [x] **T014**:F048可信来源读取 + +**文件**:`src/backend/bisheng/permission/application/runtime.py`;`src/backend/bisheng/permission/application/control_state.py`。\ +**逻辑/上下文**:添加read_effective_action_sources,复用目录与投影,每页500条/最多5000来源,前后版本复核;原通用roster仍manage_permission。\ +**依赖**:T013,T041。\ + +### T015 · 测试 + +- [x] **T015**:当前授权摘要业务测试 + +**文件**:`src/backend/test/app_publish/test_f115_current_visibility.py`。\ +**逻辑/上下文**:合法阅读上下文、绑定、真实owner资格、多类对象、owner-only/无对象/删除/失败;无历史写入。\ +**依赖**:T010,T012,T014。\ +**覆盖 AC**: AC-22, AC-23, AC-24, AC-25, AC-26, AC-27, AC-28, AC-29, AC-30, AC-31, AC-32, AC-33, AC-34, AC-35, AC-36, AC-37, AC-38, AC-52 + +### T016 · 后端 Domain + +- [x] **T016**:应用当前摘要协调 + +**文件**:`src/backend/bisheng/app_publish/domain/services/app_publish_current_visibility_service.py`;`src/backend/bisheng/app_publish/domain/schemas/current_visibility.py`。\ +**逻辑/上下文**:只在已验证审批阅读上下文内核对app/tenant,调用permission应用及目录;预算与异常显式unavailable,owner不用reader缓存。\ +**依赖**:T015。\ + +### T017 · 测试 + +- [x] **T017**:审批详情三入口与其它场景 + +**文件**:`src/backend/test/approval/test_f115_current_detail.py`。\ +**逻辑/上下文**:原鉴权优先,合法任务/个人/管理可读增量且snapshot不变,拒绝者不调摘要;其它场景无额外查询。\ +**依赖**:T016。\ +**覆盖 AC**: AC-22, AC-30, AC-31, AC-32, AC-33, AC-34, AC-35, AC-36, AC-37, AC-38, AC-52 + +### T018 · 后端 Domain + +- [x] **T018**:审批场景补充接线 + +**文件**:`src/backend/bisheng/approval/domain/services/approval_center_service.py`;`src/backend/bisheng/approval/domain/services/approval_runtime_handler_factory.py`。\ +**逻辑/上下文**:在原校验通过后用场景Handler可选只读补充;原场景决策/路由/outbox不动。\ +**依赖**:T017。\ + +### T019 · 后端 Domain + +- [x] **T019**:发布Handler与审批参考同步 + +**文件**:`src/backend/bisheng/app_publish/domain/services/app_publish_scenario_handler.py`;`.claude/skills/approval-module/SKILL.md`。\ +**逻辑/上下文**:新增read_current_detail,使用服务器上下文而非请求app_id;同步详情增量参考。\ +**依赖**:T018。\ + +### T020 · 测试 + +- [x] **T020**:Web知识库创建资格 + +**文件**:`src/backend/test/knowledge/test_f115_creation_policy.py`。\ +**逻辑/上下文**:租管无菜单允许Doc/QA,普通菜单/部门管理/跨租户/撤资格/旧会话配对;copy无扩大。\ +**依赖**:T001,T010。\ +**覆盖 AC**: AC-39, AC-40, AC-41, AC-42, AC-43, AC-44, AC-45, AC-46, AC-47, AC-48, AC-49, AC-50, AC-52 + +### T021 · 后端 Domain + +- [x] **T021**:知识库新建策略 + +**文件**:`src/backend/bisheng/knowledge/domain/services/knowledge_creation_policy.py`。\ +**逻辑/上下文**:服务端真实会话与expected所属租户比较,原菜单或当前租管资格,只产生短时服务器上下文。\ +**依赖**:T020。\ + +### T022 · 测试 + +- [x] **T022**:创建入口写前门禁 + +**文件**:`src/backend/test/knowledge/test_f115_creation_entry.py`。\ +**逻辑/上下文**:原模型校验后、实际写入前重验;失败无资源/索引;开放通道不带Web门禁,copy保持原规则。\ +**依赖**:T021。\ +**覆盖 AC**: AC-39, AC-40, AC-41, AC-42, AC-43, AC-44, AC-45, AC-46, AC-47, AC-48, AC-49, AC-50, AC-52 + +### T023 · 后端 API + +- [x] **T023**:Web新建与原异步创建复用 + +**文件**:`src/backend/bisheng/knowledge/api/endpoints/knowledge.py`;`src/backend/bisheng/knowledge/domain/services/knowledge_service.py`。\ +**逻辑/上下文**:Web DTO接线至已有acreate_knowledge,写前重验上下文;严格所属tenant scope,原模型/投影/索引继续。\ +**依赖**:T022。\ + +### T024 · 测试 + +- [x] **T024**:Platform运行和审计交互 + +**文件**:`src/frontend/platform/src/test/f115RuntimeReview.test.tsx`。\ +**逻辑/上下文**:运行0/null/未知和时间,真实访客混合/空/搜索空/失败重试/范围变化,使用现有RTL与API mock。\ +**依赖**:无。\ +**覆盖 AC**: AC-01, AC-02, AC-03, AC-04, AC-05, AC-06, AC-07, AC-08, AC-09, AC-10, AC-11, AC-12, AC-13, AC-14, AC-15, AC-16, AC-17, AC-18, AC-19, AC-20, AC-21, AC-51, AC-52 + +### T025 · 前端 Platform + +- [x] **T025**:当前实例显示 + +**文件**:`src/frontend/platform/src/controllers/API/hostedApp.ts`;`src/frontend/platform/src/pages/BuildPage/hostedApp/tabs/PublishTab.tsx`。\ +**逻辑/上下文**:nullable计数、observation_state及时间校验,未知不raw英文不??0,业务状态独立。\ +**依赖**:T008,T024。\ +**验证**:配对组件测试、三语和局部静态检查;实际页面检查见 E2E 清单。 + +### T026 · 前端 Platform + +- [x] **T026**:模型用户过滤状态 + +**文件**:`src/frontend/platform/src/pages/LogPage/runtime/RuntimeAuditPanel.tsx`。\ +**逻辑/上下文**:共享Select空/搜索/加载文案与失败重试,key完整,范围变化重置旧身份搜索,不改接口/账本。\ +**依赖**:T024。\ +**验证**:配对组件测试、三语和局部静态检查;实际页面检查见 E2E 清单。 + +### T027 · 测试 + +- [x] **T027**:Platform新建与管理审批摘要 + +**文件**:`src/frontend/platform/src/test/f115KnowledgeCreation.test.tsx`;`src/frontend/platform/src/test/f115ApprovalVisibility.test.tsx`。\ +**逻辑/上下文**:Doc/QA按钮与copy拆分,固定leaf租户;异常详情当前范围刷新及scope隔离、不可确认。\ +**依赖**:无。\ +**覆盖 AC**: AC-22, AC-24, AC-25, AC-28, AC-29, AC-30, AC-32, AC-35, AC-36, AC-37, AC-38, AC-39, AC-40, AC-41, AC-42, AC-43, AC-44, AC-45, AC-46, AC-47, AC-48, AC-49, AC-50, AC-51, AC-52 + +### T028 · 前端 Platform + +- [x] **T028**:新建复制条件分离 + +**文件**:`src/frontend/platform/src/pages/KnowledgePage/KnowledgeFile.tsx`;`src/frontend/platform/src/pages/KnowledgePage/KnowledgeQa.tsx`。\ +**逻辑/上下文**:本租管加入新建条件,copy仍原菜单/超管与源读取资格。\ +**依赖**:T027。\ +**验证**:配对组件测试、三语和局部静态检查;实际页面检查见 E2E 清单。 + +### T029 · 前端 Platform + +- [x] **T029**:两类表单固定所属租户 + +**文件**:`src/frontend/platform/src/pages/KnowledgePage/components/KnowledgeLibraryDialog.tsx`;`src/frontend/platform/src/pages/KnowledgePage/components/QaLibraryDialog.tsx`。\ +**逻辑/上下文**:打开时固定leaf_tenant_id发送expected_tenant_id,无有效归属不提交,非管理视角。\ +**依赖**:T023,T027。\ +**验证**:配对组件测试、三语和局部静态检查;实际页面检查见 E2E 清单。 + +### T030 · 前端 Platform + +- [x] **T030**:管理摘要类型与重查 + +**文件**:`src/frontend/platform/src/controllers/API/approval.ts`;`src/frontend/platform/src/pages/ApprovalPage/useApprovalExceptions.ts`。\ +**逻辑/上下文**:同级current_visibility类型;按scope/instance重查,展开或刷新不使用永久旧授权缓存。\ +**依赖**:T019,T027。\ +**验证**:配对组件测试、三语和局部静态检查;实际页面检查见 E2E 清单。 + +### T043 · 前端 Platform + +- [x] **T043**:管理异常页刷新接线 + +**文件**:`src/frontend/platform/src/pages/ApprovalPage/useApprovalController.ts`。\ +**逻辑/上下文**:将真实重查回调传给异常详情;租户切换沿既有全页重载清空内存缓存。\ +**依赖**:T030。\ + +### T031 · 前端 Platform + +- [x] **T031**:异常详情只读摘要 + +**文件**:`src/frontend/platform/src/pages/ApprovalPage/ExceptionPanel.tsx`;`src/frontend/platform/src/pages/ApprovalPage/CurrentAppVisibility.tsx`。\ +**逻辑/上下文**:小型只读区域,当前/owner-only/无对象/删除/不可确认三语,不复制client页面或增加审批权限。\ +**依赖**:T030,T043。\ +**验证**:配对组件测试、三语和局部静态检查;实际页面检查见 E2E 清单。 + +### T032 · 前端 Platform + +- [x] **T032**:中文与英文文案 + +**文件**:`src/frontend/platform/public/locales/zh-Hans/bs.json`;`src/frontend/platform/public/locales/en-US/bs.json`。\ +**逻辑/上下文**:新增运行未知、访客无记录、审批当前授权与资格反馈;沿现有命名空间。\ +**依赖**:T025,T026,T028,T029,T031。\ +**验证**:配对组件测试、三语和局部静态检查;实际页面检查见 E2E 清单。 + +### T033 · 前端 Platform + +- [x] **T033**:日文与运行既有命名空间 + +**文件**:`src/frontend/platform/public/locales/ja/bs.json`。\ +**逻辑/上下文**:日文与中文英文同义;新增提示显式使用bs命名空间,不修改共享生成文案。\ +**依赖**:T032。\ +**验证**:配对组件测试、三语和局部静态检查;实际页面检查见 E2E 清单。 + +### T034 · 测试 + +- [x] **T034**:Client当前审批范围 + +**文件**:`src/frontend/client/src/components/approval/AppPublishDetailPanel.test.tsx`;`src/frontend/client/src/components/approval/CurrentAppVisibility.test.tsx`。\ +**逻辑/上下文**:当前DTO优先、快照不变、首发非必owner-only、失败/删除/真实对象、权限不扩展、刷新回调。\ +**依赖**:无。\ +**覆盖 AC**: AC-22, AC-24, AC-25, AC-27, AC-28, AC-29, AC-30, AC-31, AC-32, AC-33, AC-34, AC-35, AC-36, AC-37, AC-38, AC-51, AC-52 + +### T035 · 前端 Client + +- [x] **T035**:当前摘要类型与渲染 + +**文件**:`src/frontend/client/src/api/approval.ts`;`src/frontend/client/src/components/approval/CurrentAppVisibility.tsx`。\ +**逻辑/上下文**:独立current_visibility字段,旧后端显示未知,冻结区域不读动态payload,刷新通过父层回调。\ +**依赖**:T019,T034。\ +**验证**:配对组件测试、三语和局部静态检查;实际页面检查见 E2E 清单。 + +### T042 · 前端 Client + +- [x] **T042**:Client发布卡片当前摘要接线 + +**文件**:`src/frontend/client/src/components/approval/AppPublishDetailPanel.tsx`。\ +**逻辑/上下文**:移除旧可见范围快照渲染,接入独立当前摘要;其它冻结内容保持。\ +**依赖**:T035。\ + +### T036 · 前端 Client + +- [x] **T036**:审批父层刷新 + +**文件**:`src/frontend/client/src/components/approval/ApprovalDetailPanels.tsx`;`src/frontend/client/src/components/approval/ApprovalPane.tsx`。\ +**逻辑/上下文**:在已核实父层接入详情重查,打开/刷新重新鉴权,不混SPA。\ +**依赖**:T035,T042。\ +**验证**:配对组件测试、三语和局部静态检查;实际页面检查见 E2E 清单。 + +### T037 · 前端 Client + +- [x] **T037**:中文英文当前摘要文案 + +**文件**:`src/frontend/client/src/locales/zh-Hans/translation.json`;`src/frontend/client/src/locales/en/translation.json`。\ +**逻辑/上下文**:补当前范围、管理员说明、无对象/失败/删除/refresh,纯文本对象名称。\ +**依赖**:T035。\ +**验证**:配对组件测试、三语和局部静态检查;实际页面检查见 E2E 清单。 + +### T038 · 前端 Client + +- [x] **T038**:日文当前摘要文案 + +**文件**:`src/frontend/client/src/locales/ja/translation.json`。\ +**逻辑/上下文**:日文与中文英文含义一致。\ +**依赖**:T037。\ +**验证**:配对组件测试、三语和局部静态检查;实际页面检查见 E2E 清单。 + +### T039 · 测试 + +- [x] **T039**:定向E2E脚本和页面清单(已准备;真实执行待授权) + +**文件**:`src/backend/test/e2e/test_e2e_f115_runtime_review.py`;`features/v3.0.0/115-vibe-runtime-review-fixes/e2e-checklist.md`。\ +**逻辑/上下文**:专用e2e-f115数据,只跑四链路;真实部署与写入须独立环境授权,未执行如实登记,禁止全量测试。\ +**依赖**:T006,T008,T019,T023,T026,T029,T031,T036,T038。\ +**覆盖 AC**: AC-01, AC-02, AC-03, AC-04, AC-05, AC-06, AC-07, AC-08, AC-09, AC-10, AC-11, AC-12, AC-13, AC-14, AC-15, AC-16, AC-17, AC-18, AC-19, AC-20, AC-21, AC-22, AC-23, AC-24, AC-25, AC-26, AC-27, AC-28, AC-29, AC-30, AC-31, AC-32, AC-33, AC-34, AC-35, AC-36, AC-37, AC-38, AC-39, AC-40, AC-41, AC-42, AC-43, AC-44, AC-45, AC-46, AC-47, AC-48, AC-49, AC-50, AC-51, AC-52 + +### T040 · 后端 Domain + +- [x] **T040**:定向门禁与L1/L2报告 + +**文件**:`features/v3.0.0/115-vibe-runtime-review-fixes/verification.md`;`features/v3.0.0/115-vibe-runtime-review-fixes/code-review.md`。\ +**逻辑/上下文**:记录精确命令/结果/环境边界、task-review及7维特性审查;同步设计实际接线和完成状态,不将未部署E2E标完成。\ +**依赖**:T039。\ + +## 实际偏差记录 + +开发接线新增四个原子任务 T041–T044;修正实际文件名及父层路径,业务边界不变。共享错误码产物由 locales build 生成,不手工编辑。T039 完成脚本/清单与离线收集,不代表真实 E2E 通过;真实验收保持未执行。Jest 依赖启动受阻时,使用临时 Vitest 兼容副本验证相同组件和断言,未改仓库测试框架。L1 逐项结果及 L2 见 verification.md / code-review.md。 + +## tasks 自审 + +LGTM + +按当前 tasks-checklist 实际21项自审:AC定义连续且测试覆盖全部52条;依赖均存在且在前;后端测试在配对实现前;每项至多2个文件;Platform/Client分区;无Worker/DDL不适用项已说明。每项明确文件、具体产出与上下文,无测试降级或业务TODO。仅开发阶段所需定向测试和门禁,不包含环境部署授权。 diff --git a/features/v3.0.0/115-vibe-runtime-review-fixes/verification.md b/features/v3.0.0/115-vibe-runtime-review-fixes/verification.md new file mode 100644 index 0000000000..6754ff9eed --- /dev/null +++ b/features/v3.0.0/115-vibe-runtime-review-fixes/verification.md @@ -0,0 +1,111 @@ +# F115 开发与定向验证记录 + +日期:2026-11-02。分支 `codex/vibe-runtime-review-20261102`;源码基线 `497256d289793ce016df07cf8ab38f32988faca4`。规格52条及设计均获用户分别确认。本轮未部署;不运行全量测试。 + +## 本次 Git 交付安排 + +用户在本地开发与验收缺口已披露后,明确要求“提交推送 feat/3.0.0 分支”。按该指令先提交本功能、合入最新远端发版线并推送,保留目标分支后续增强。真实部署 E2E 及既有类型门禁问题仍按下表记录,不将 Git 交付视为部署验收完成。本次不更新环境或存量资源。 + +## 本地结果 + +| 范围 | 实际结果 | 证据/限制 | +|---|---|---| +| 后端新增链路与直接相关知识库回归 | 63 passed;5个真实E2E用例默认skipped | pytest实际组合运行;非空业务断言、拒绝先于名单、原子创建补偿及开放API兼容 | +| manager Docker/K8s只读观察 | 71 passed | test_f115_observation.py + test_k8s_observe.py;fake外部平台/执行器,非真实部署 | +| Platform组件和直接审计回归 | 19 passed / 4 files | 运行状态、候选失败/空/切范围、知识库两种入口与旧表单、审批重查 | +| Client审批组件/父层相关断言 | 22 passed / 3 files | 现有Jest受未构建canvas与ESM p-limit启动问题阻断;临时Vitest兼容副本仅替换测试全局名称/模块定位和JSX运行配置,执行真实产品组件及同一断言。没有修改仓库runner或依赖安装 | +| 前端 lint | PASS | 按根脚本检查两个SPA与共享包 | +| i18n与错误码产物 | PASS | check-i18n;locales build/check,三语源和6个生成文件 | +| 前端类型门禁 | 未全绿;无新增诊断 | 同一基线/同一依赖对照:Platform23条、Client134条;依赖缺失、旧图标/API等既有报错。本轮曾新增一处测试fixture类型错误,已修正并重新验证 | +| Ruff | 无新增问题 | 修改文件与git HEAD同配置对照;旧文件剩余问题按基线登记,新文件通过。所有修改Python执行format及未定义名检查 | +| 架构守卫 | 无VIOLATION | knowledge endpoint旧database/models导入仅RULE-3既有WARNING | +| 真实部署 E2E | 未执行 | 5个用例只作离线收集/默认skip;未访问或写入116/189。必须依e2e-test及design §7.2在明确授权和部署后执行 | + +## 可复现命令 + +工作目录分别为worktree的 `src/backend`、`src/runtime-manager`、`src/frontend/platform`。Python复用主工作区现有venv;config只指定既有本地文件路径,不复制或输出凭据。pytest使用 `-q -p no:cacheprovider --tb=short --disable-warnings`。 + +```text +pytest test/app_runtime/test_f115_instance_observation.py test/user/test_f115_current_identity.py test/tenant/test_f115_visibility_subjects.py test/permission/test_f115_action_sources.py test/app_publish/test_f115_current_visibility.py test/approval/test_f115_current_detail.py test/knowledge/test_f115_creation_policy.py test/knowledge/test_f115_creation_entry.py test/knowledge/test_f115_e2e_cleanup.py test/knowledge/test_knowledge_create_atomicity.py test/knowledge/test_v2_filelib_unified.py test/e2e/test_e2e_f115_runtime_review.py +pytest tests/test_f115_observation.py tests/test_k8s_observe.py +vitest run src/test/f115RuntimeReview.test.tsx src/test/f115KnowledgeCreation.test.tsx src/test/f115ApprovalVisibility.test.tsx src/test/runtimeAudit.test.tsx +pnpm lint +pnpm typecheck +pnpm check-i18n +node packages/locales/scripts/build.mjs --check +``` + +临时日志位于 `/private/tmp/f115-*.log`;兼容运行及基线只存在临时目录,没有提交对依赖或测试环境的改写。 + +## task-review(L1) + +44个原子任务逐项检查命名/分层、响应序列化、SQL归属与严格租户、权限应用端口、两SPA/i18n、敏感信息、设计同步。实现任务配对用例及修改文件与tasks对应;基础设施和测试任务按skill的差异化清单检查。代码项PASS;前端旧类型/Ruff基线与真实E2E门禁为显式备注,不能记作远端已验收。 + +- T001–T003:10993未冲突、Web专用DTO不扩展共享创建目标、三语产物一致。 +- T004–T008:真实当前绑定/UID、HTTP事实、nullable/0及UTC归一;旧观察404不回退历史AppInstance。 +- T009–T016、T041:实时人员资格、批量目录、CURRENT及完整分页/版本复核;真实owner资格与阅读者分离,普通审批人不获得manage_permission。 +- T017–T019:三类详情均在原守卫后追加;非app场景无动态查询,两个snapshot不写回。 +- T020–T023:准备和写前重验,严格所属租户;copy/开放API不增加菜单门禁,原创建补偿保留。 +- T024–T033、T043:前端状态与真实字段一致,表单固定leaf、不以当前管理视角写入;管理异常页重查与序号拒绝旧响应。 +- T034–T038、T042:Client当前范围只读、typed userset、失败/删除/owner-only与冻结区域分开,父层真实API刷新。 +- T039、T044:专用E2E准备、显式启用和精确清理护栏;非测试名称与跨租户清理用例拒绝。 +- T040:记录本页和L2结论,完整实现52条目标的本地接线,不声称52条均在真实环境验收。 + +## AC追溯 + +下表中的“本地”指单元/组件/相关回归与代码路径核对;所有真实部署项均待验,具体步骤见 [e2e-checklist.md](./e2e-checklist.md)。不把测试任务写过覆盖编号当成真实验收证据。 + +| AC | 本地证据入口 | 真实环境 | +|---|---|---| +| AC-01 | manager test_f115_observation/test_k8s_observe;backend instance;Platform f115RuntimeReview | 待部署验收 | +| AC-02 | manager test_f115_observation/test_k8s_observe;backend instance;Platform f115RuntimeReview | 待部署验收 | +| AC-03 | manager test_f115_observation/test_k8s_observe;backend instance;Platform f115RuntimeReview | 待部署验收 | +| AC-04 | manager test_f115_observation/test_k8s_observe;backend instance;Platform f115RuntimeReview | 待部署验收 | +| AC-05 | manager test_f115_observation/test_k8s_observe;backend instance;Platform f115RuntimeReview | 待部署验收 | +| AC-06 | manager test_f115_observation/test_k8s_observe;backend instance;Platform f115RuntimeReview | 待部署验收 | +| AC-07 | manager test_f115_observation/test_k8s_observe;backend instance;Platform f115RuntimeReview | 待部署验收 | +| AC-08 | manager test_f115_observation/test_k8s_observe;backend instance;Platform f115RuntimeReview | 待部署验收 | +| AC-09 | manager test_f115_observation/test_k8s_observe;backend instance;Platform f115RuntimeReview | 待部署验收 | +| AC-10 | manager test_f115_observation/test_k8s_observe;backend instance;Platform f115RuntimeReview | 待部署验收 | +| AC-11 | 既有审计repository可信身份过滤核对;runtimeAudit及f115RuntimeReview组件 | 待部署验收 | +| AC-12 | 既有审计repository可信身份过滤核对;runtimeAudit及f115RuntimeReview组件 | 待部署验收 | +| AC-13 | 既有审计repository可信身份过滤核对;runtimeAudit及f115RuntimeReview组件 | 待部署验收 | +| AC-14 | 既有审计repository可信身份过滤核对;runtimeAudit及f115RuntimeReview组件 | 待部署验收 | +| AC-15 | 既有审计repository可信身份过滤核对;runtimeAudit及f115RuntimeReview组件 | 待部署验收 | +| AC-16 | 既有审计repository可信身份过滤核对;runtimeAudit及f115RuntimeReview组件 | 待部署验收 | +| AC-17 | 既有审计repository可信身份过滤核对;runtimeAudit及f115RuntimeReview组件 | 待部署验收 | +| AC-18 | 既有审计repository可信身份过滤核对;runtimeAudit及f115RuntimeReview组件 | 待部署验收 | +| AC-19 | 既有审计repository可信身份过滤核对;runtimeAudit及f115RuntimeReview组件 | 待部署验收 | +| AC-20 | 既有审计repository可信身份过滤核对;runtimeAudit及f115RuntimeReview组件 | 待部署验收 | +| AC-21 | 既有审计repository可信身份过滤核对;runtimeAudit及f115RuntimeReview组件 | 待部署验收 | +| AC-22 | action_sources/current_identity/visibility_subjects/current_visibility/current_detail;两端CurrentAppVisibility | 待部署验收 | +| AC-23 | action_sources/current_identity/visibility_subjects/current_visibility/current_detail;两端CurrentAppVisibility | 待部署验收 | +| AC-24 | action_sources/current_identity/visibility_subjects/current_visibility/current_detail;两端CurrentAppVisibility | 待部署验收 | +| AC-25 | action_sources/current_identity/visibility_subjects/current_visibility/current_detail;两端CurrentAppVisibility | 待部署验收 | +| AC-26 | action_sources/current_identity/visibility_subjects/current_visibility/current_detail;两端CurrentAppVisibility | 待部署验收 | +| AC-27 | action_sources/current_identity/visibility_subjects/current_visibility/current_detail;两端CurrentAppVisibility | 待部署验收 | +| AC-28 | action_sources/current_identity/visibility_subjects/current_visibility/current_detail;两端CurrentAppVisibility | 待部署验收 | +| AC-29 | action_sources/current_identity/visibility_subjects/current_visibility/current_detail;两端CurrentAppVisibility | 待部署验收 | +| AC-30 | action_sources/current_identity/visibility_subjects/current_visibility/current_detail;两端CurrentAppVisibility | 待部署验收 | +| AC-31 | action_sources/current_identity/visibility_subjects/current_visibility/current_detail;两端CurrentAppVisibility | 待部署验收 | +| AC-32 | action_sources/current_identity/visibility_subjects/current_visibility/current_detail;两端CurrentAppVisibility | 待部署验收 | +| AC-33 | action_sources/current_identity/visibility_subjects/current_visibility/current_detail;两端CurrentAppVisibility | 待部署验收 | +| AC-34 | action_sources/current_identity/visibility_subjects/current_visibility/current_detail;两端CurrentAppVisibility | 待部署验收 | +| AC-35 | action_sources/current_identity/visibility_subjects/current_visibility/current_detail;两端CurrentAppVisibility | 待部署验收 | +| AC-36 | action_sources/current_identity/visibility_subjects/current_visibility/current_detail;两端CurrentAppVisibility | 待部署验收 | +| AC-37 | action_sources/current_identity/visibility_subjects/current_visibility/current_detail;两端CurrentAppVisibility | 待部署验收 | +| AC-38 | action_sources/current_identity/visibility_subjects/current_visibility/current_detail;两端CurrentAppVisibility | 待部署验收 | +| AC-39 | creation_policy/creation_entry;knowledge_create_atomicity/v2_filelib;Platform f115KnowledgeCreation | 待部署验收 | +| AC-40 | creation_policy/creation_entry;knowledge_create_atomicity/v2_filelib;Platform f115KnowledgeCreation | 待部署验收 | +| AC-41 | creation_policy/creation_entry;knowledge_create_atomicity/v2_filelib;Platform f115KnowledgeCreation | 待部署验收 | +| AC-42 | creation_policy/creation_entry;knowledge_create_atomicity/v2_filelib;Platform f115KnowledgeCreation | 待部署验收 | +| AC-43 | creation_policy/creation_entry;knowledge_create_atomicity/v2_filelib;Platform f115KnowledgeCreation | 待部署验收 | +| AC-44 | creation_policy/creation_entry;knowledge_create_atomicity/v2_filelib;Platform f115KnowledgeCreation | 待部署验收 | +| AC-45 | creation_policy/creation_entry;knowledge_create_atomicity/v2_filelib;Platform f115KnowledgeCreation | 待部署验收 | +| AC-46 | creation_policy/creation_entry;knowledge_create_atomicity/v2_filelib;Platform f115KnowledgeCreation | 待部署验收 | +| AC-47 | creation_policy/creation_entry;knowledge_create_atomicity/v2_filelib;Platform f115KnowledgeCreation | 待部署验收 | +| AC-48 | creation_policy/creation_entry;knowledge_create_atomicity/v2_filelib;Platform f115KnowledgeCreation | 待部署验收 | +| AC-49 | creation_policy/creation_entry;knowledge_create_atomicity/v2_filelib;Platform f115KnowledgeCreation | 待部署验收 | +| AC-50 | creation_policy/creation_entry;knowledge_create_atomicity/v2_filelib;Platform f115KnowledgeCreation | 待部署验收 | +| AC-51 | 三语/架构守卫/拒绝场景与对应路径 | 待部署验收 | +| AC-52 | 三语/架构守卫/拒绝场景与对应路径 | 待部署验收 | diff --git a/features/v3.0.0/release-contract.md b/features/v3.0.0/release-contract.md index 9667ee8866..09644923ed 100644 --- a/features/v3.0.0/release-contract.md +++ b/features/v3.0.0/release-contract.md @@ -22,6 +22,7 @@ | 领域对象 | Owner Feature | 说明 | |---------|--------------|------| +| 无新增独立领域对象;运行信息、审批当前可见范围与知识库新建资格增量 | 原 Owner 不变:F054/F059/F086 运行信息、F056 审计查询、F055 审批场景;F048 权限、审批引擎及知识库/组织领域继续拥有各自对象;F115 协调扩展 | [F115](./115-vibe-runtime-review-fixes/spec.md) 需求发现 D1–D4 及 52 条正式规格于 2026-11-02 获用户分别确认;设计已获单独确认,代码与本地定向验证已实现;真实部署E2E尚未执行。审批展示修订 F055 AC-24,并对 F114 AC-33 追加限定的审批只读摘要例外;不修改 INV、不迁移资源、不新增权限动作 | | 无新增独立领域对象;应用发布说明增量 | 原 Owner 不变:F055 发布输入与审批场景、F054 AppVersion、F053 CLI;F112-vibe-release-notes 协调扩展 | [F112](./112-vibe-release-notes/spec.md) 规格与设计已于 2026-10-27 获用户确认,实现及本地定向验证完成,真实部署 E2E 待执行。说明按发布冻结,在审批详情和版本记录展示,不取得运行时、凭据或审批引擎对象所有权 | | **ApiCredential**(平台自签发凭据底座:服务账号密钥 `bs-sak-` + **个人访问令牌 `bs-pat-`**;~~全平台唯一主体轨,`bs-pat-` 个人主体轨已废除~~ — **2026-08-27 改判**:伴生 PRD v2.4 §7.6 **D13** 恢复自然人主体轨,服务于伴生 §3.5 场景四(员工自助接入第三方办公 Agent 检索本人有权访问的内容)。F049 决议-4「全平台只留服务账号一类主体」在**系统集成**语境下依然成立、在场景四被取代。两类主体**共用同一底座**(同一张表、同一条校验路径、同一套哈希 / 撤销 / 租户隔离语义),差异只有五处,见伴生 §4.10.2)。**主体类型只有两种:`service_account`(密钥 `bs-sak-`)/ `natural_person`(令牌 `bs-pat-`)**(beta2 `api_credential.subject_kind` CHECK 约束);`hosted_app` 主体本轮不做(迁移方案 M10) | **F049-openapi-auth-baseline**(**实现由 beta2 F053 承接,vibe 实现归档**,2026-09-10,见 [beta2-openapi-base-migration.md](./beta2-openapi-base-migration.md)) | 拥有凭据的生成、哈希存储、校验、权限位(含三扩展位 `model:invoke` / `identity:read` / `app:manage` 的清单登记)、过期、软撤销与批量撤销、最后使用时间写入。三种用途共用本底座、不分凭据类型:开放 API 集成密钥与 PRD-1 开发者 key(同一张清单、勾哪些位的差别)由管理员签发;**应用运行期凭据**由 F055 随发布自动签发 / 回收(不作产品概念、无管理入口,PRD-1 GOV-08)。F050 追加 `delegate` 位与委托范围属性,不改底座语义。会话派生凭据已随「会话 key」移出 PRD-1、归 PRD-2 | | **ServiceAccount**(不可登录的服务账号主体:**独立 `service_account` 表**〔不在 `user` 表上打标,迁移方案 M4〕+ 租户归属 + **资源归属人**(必填自然人,伴生 PRD §4.5 定义 6)+ 登录守卫 + 选人场景排除 + 主体侧授权页) | **F049-openapi-auth-baseline**(实现由 beta2 F053 承接,vibe 实现归档) | 伴生 PRD R4 / R6。拥有服务账号的创建 / 启停 / 删除、租户关联行的声明式写入、对账豁免、选人场景默认排除、资源归属人的写入与「创建关系落归属人 + 回授可编辑」语义、主体侧资源授权页;不拥有 `User` 表本身(自然人侧行为不变)。**服务账号不得被授予管理员身份、不得被授予任何角色、不得被主动加入用户组 / 部门**(F049 AC-22) | @@ -333,3 +334,23 @@ F086/F059 实现汇合到 `feat/3.0.0-vibe`。F059 原 267/271,加同版本 CI ### F114 本地实施记录(2026-10-30) 用户“确认设计,进入任务拆解和开发”后,六动作与具体租户、真实提交主体、唯一v2分阶段授权、独立预览身份和对应前端完成本地接线。新增SQL查询归属Repository;保留原owner/tenant、不回填存量授权、不迁移资源。已执行本功能必要定向验证,实际数量/命令、旧fixture退役及前端现有环境门禁阻塞见[verification](./114-vibe-permission-actions/verification.md)。本轮未更新116/189,未新增公开推送、tag或镜像发行;真实E2E及最终L2保持未完成。上方各轮设计确认前的记录属于历史阶段,不代表当前还需重复确认设计。 + +## F115 增量需求记录(2026-11-02;规格/设计均已确认,本地实现) + +用户已分别确认 [F115 discovery](./115-vibe-runtime-review-fixes/discovery.md) D1–D4 与 [F115 spec](./115-vibe-runtime-review-fixes/spec.md) 52 条 AC,及 [F115 design](./115-vibe-runtime-review-fixes/design.md),并明确要求任务拆解与开发。代码与本地定向验证已完成,见 [verification](./115-vibe-runtime-review-fixes/verification.md);尚未更新环境或执行真实E2E。 + +- Docker/K8s 运行信息只反映当前绑定的应用执行体,健康依实际探活/就绪事实,计数零值与未知分开,不为显示而重建应用。 +- 访问用户候选与审计查询使用相同授权范围,保留真实自然人身份;不从应用自身调用推测人员,不回填历史调用。 +- 已确认将 F055 AC-24 的“提交时可见范围快照”展示部分替换为查询时当前有效普通访问授权;其它冻结内容与审批历史保持。F114 AC-33 追加“有权阅读该次审批的人可查看对应应用当前只读摘要”的窄例外,审批外完整目录和授权变更仍要求 manage_permission。此处确认的是目标合同,不宣称代码或部署已生效。 +- 已确认租户管理员获得所属租户文档/QA 知识库新建资格,不扩大复制、普通用户或一般部门管理员资格,不改创建人和租户归属。 +- F048、F054/F055/F056、F059/F086 及知识库/组织/审批领域归属不变,不新建领域对象、不修改 INV、不迁移存量资源或补写授权。仅做本功能必要定向验证;本记录不授权服务更新或环境写入。 + +### F115 已分配错误码(本地实现) + +| 码 | 计划类 | 原模块 | 含义 | +| --- | --- | --- | --- | +| 10993 | KnowledgeCreationScopeChangedError | knowledge(109) | Web 新建表单预期所属租户与实际当前身份不一致;刷新/重开表单。基线及实施扫描确认未占用;三语源和生成产物同时交付 | + +### F115 发版线交付指令(2026-11-02) + +用户在已披露本地验证结果、既有前端类型诊断及真实 E2E 待验后,明确要求“提交推送 feat/3.0.0 分支”。本次按该指令先提交、合入最新远端 feat/3.0.0 并推送,保留已有后续增强;真实部署验收仍待执行,不更新环境、不迁移存量资源。具体证据与限制保留在 F115 verification 和 code-review。 diff --git a/src/backend/bisheng/app_publish/domain/schemas/current_visibility.py b/src/backend/bisheng/app_publish/domain/schemas/current_visibility.py new file mode 100644 index 0000000000..fc7e5e02d9 --- /dev/null +++ b/src/backend/bisheng/app_publish/domain/schemas/current_visibility.py @@ -0,0 +1,26 @@ +"""Read-only current decoration, separate from immutable approval snapshots.""" + +from dataclasses import dataclass +from datetime import UTC, datetime + + +@dataclass(frozen=True, slots=True) +class ApprovalVisibilityRead: + instance_id: int + tenant_id: int + app_id: str + viewer_user_id: int + management: bool = False + payload_app_id: str | None = None + + +def visibility_state(state, *, reason=None, owner=None, subjects=None, owner_only=None): + return { + "state": state, + "checked_at": datetime.now(UTC).isoformat().replace("+00:00", "Z"), + "owner_only": owner_only, + "owner": owner, + "subjects": subjects or [], + "admin_implicit_access": True, + "reason": reason, + } diff --git a/src/backend/bisheng/app_publish/domain/services/app_publish_current_visibility_service.py b/src/backend/bisheng/app_publish/domain/services/app_publish_current_visibility_service.py new file mode 100644 index 0000000000..22c87e7f56 --- /dev/null +++ b/src/backend/bisheng/app_publish/domain/services/app_publish_current_visibility_service.py @@ -0,0 +1,114 @@ +"""Current use recipients, only after approval's existing read qualification.""" + +import asyncio +from time import monotonic + +from loguru import logger + +from bisheng.app_publish.domain.repositories.preview_runtime_repository import PreviewRuntimeRepository +from bisheng.app_publish.domain.schemas.current_visibility import visibility_state +from bisheng.common.dependencies.user_deps import UserPayload +from bisheng.common.errcode.approval import ApprovalRequestPermissionDeniedError +from bisheng.common.errcode.permission import PermissionPublishNotReadyError, PermissionSubjectUnavailableError +from bisheng.core.context.tenant import management_tenant_scope, strict_tenant_filter, tenant_execution_scope +from bisheng.permission.application import is_tenant_admin +from bisheng.permission.application.access import ( + get_f048_resource_adapter, + get_f048_runtime, + get_grant_subject_directory, +) +from bisheng.permission.application.identity import PermissionActor +from bisheng.user.application.runtime_identity import read_current_person_facts + +read_application = PreviewRuntimeRepository.read_application + + +async def _person_actor(person): + """Resolve this exact person, never the current request's cached actor.""" + global_super = bool(getattr(person, "is_global_super", False)) + tenant_admin = not global_super and await is_tenant_admin(person.user_id, person.tenant_id) + return PermissionActor( + subject_type="user", + subject_id=person.user_id, + tenant_id=person.tenant_id, + super_admin=global_super, + tenant_admin_tenant_ids=frozenset({person.tenant_id}) if tenant_admin else frozenset(), + ) + + +class AppPublishCurrentVisibilityService: + @classmethod + async def read(cls, context, viewer): + if ( + context.viewer_user_id != viewer.user_id + or not context.app_id + or (context.payload_app_id is not None and context.payload_app_id != context.app_id) + or ( + viewer.tenant_id != context.tenant_id + and not (context.management and getattr(viewer, "is_global_super", False)) + ) + ): + raise ApprovalRequestPermissionDeniedError() + start = monotonic() + try: + app = await asyncio.wait_for(read_application(context.app_id), timeout=5) + except Exception: + # Current decoration is noncritical; report the gap, preserve the frozen card. + logger.exception("app_publish.current_visibility read failed instance={}", context.instance_id) + return visibility_state("unavailable", reason="application_unavailable") + if app is None: + return visibility_state("deleted", reason="application_deleted") + if app.id != context.app_id or app.tenant_id != context.tenant_id: + raise ApprovalRequestPermissionDeniedError() + if app.state == "deleted": + return visibility_state("deleted", reason="application_deleted") + scope = management_tenant_scope if context.management else tenant_execution_scope + try: + with scope(context.tenant_id), strict_tenant_filter(): + return await asyncio.wait_for(cls._assemble(app, viewer), timeout=max(0.001, 5 - (monotonic() - start))) + except Exception: + # Never turn an incomplete optional read into an owner-only assertion. + logger.exception( + "app_publish.current_visibility unavailable instance={} app={}", context.instance_id, app.id + ) + return visibility_state("unavailable", reason="authorization_unavailable") + + @staticmethod + async def _assemble(app, viewer): + runtime = await get_f048_runtime() + adapter = await get_f048_resource_adapter("app") + target = await adapter.resolve_permission_target( + resource_id=app.id, actor=await _person_actor(viewer), action="use" + ) + sources = await runtime.read_effective_action_sources(target=target, action="use") + directory = await get_grant_subject_directory() + subjects = await directory.describe_active_sources(tenant_id=app.tenant_id, sources=sources) + facts = (await read_current_person_facts([app.owner_user_id])).get(app.owner_user_id) + if facts is None or facts["state"] == "ambiguous": + raise PermissionSubjectUnavailableError(reason="owner_unresolved") + eligible = False + if facts["state"] == "active" and facts["tenant_id"] == app.tenant_id: + if not facts["name"]: + raise PermissionSubjectUnavailableError(reason="owner_name_unresolved") + owner = await UserPayload.init_login_user( + user_id=app.owner_user_id, + user_name=facts["name"], + tenant_id=facts["tenant_id"], + token_version=facts["token_version"], + ) + eligible = await runtime.check_action(await _person_actor(owner), target, "use") + owner_id = str(app.owner_user_id) + subjects = [row for row in subjects if not (row["type"] == "user" and row["id"] == owner_id)] + owner_only = bool(eligible and not subjects) + if eligible: + subjects.insert(0, {"type": "user", "id": owner_id, "name": facts["name"]}) + # Names/identity checks can cross a concurrent Grant edit. Recheck via + # the public permission port, not its private repository or state. + if sources != await runtime.read_effective_action_sources(target=target, action="use"): + raise PermissionPublishNotReadyError(reason="summary_scope_changed") + return visibility_state( + "available", + owner_only=owner_only, + owner={"id": owner_id, "name": facts["name"], "eligible": bool(eligible)}, + subjects=subjects, + ) diff --git a/src/backend/bisheng/app_publish/domain/services/app_publish_scenario_handler.py b/src/backend/bisheng/app_publish/domain/services/app_publish_scenario_handler.py index 076acebc41..9546b5c268 100644 --- a/src/backend/bisheng/app_publish/domain/services/app_publish_scenario_handler.py +++ b/src/backend/bisheng/app_publish/domain/services/app_publish_scenario_handler.py @@ -76,6 +76,22 @@ def __init__(self) -> None: # Card rendering # ------------------------------------------------------------------ + async def read_current_detail(self, *, instance, login_user, management: bool = False) -> dict: + from bisheng.app_publish.domain.schemas.current_visibility import ApprovalVisibilityRead + from bisheng.app_publish.domain.services.app_publish_current_visibility_service import ( + AppPublishCurrentVisibilityService, + ) + + context = ApprovalVisibilityRead( + instance_id=instance.id, + tenant_id=instance.tenant_id, + app_id=instance.business_resource_id, + viewer_user_id=login_user.user_id, + management=management, + payload_app_id=(instance.payload_snapshot or {}).get("app_id"), + ) + return await AppPublishCurrentVisibilityService.read(context, login_user) + async def validate(self, req, login_user) -> None: """No extra submission-time validation: the pipeline already ran precheck and the scan.""" return None diff --git a/src/backend/bisheng/app_runtime/domain/services/app_query_service.py b/src/backend/bisheng/app_runtime/domain/services/app_query_service.py index b315c0678d..d413e62243 100644 --- a/src/backend/bisheng/app_runtime/domain/services/app_query_service.py +++ b/src/backend/bisheng/app_runtime/domain/services/app_query_service.py @@ -10,11 +10,11 @@ from bisheng.common.errcode.app_factory import ( AppManageForbiddenError, AppNotFoundError, + AppOrchestratorUnavailableError, ) from bisheng.common.services.config_service import settings from bisheng.core.database import get_async_db_session from bisheng.database.models.app import App, AppDao -from bisheng.database.models.app_instance import PHASE_STOPPED, AppInstanceDao from bisheng.database.models.app_version import AppVersionDao #: Which door the caller came through. The *content* is identical for all three @@ -60,20 +60,13 @@ async def get_instance(cls, app_id: str, *, actor, entry: str = LOG_ENTRY_DETAIL "started_at", "restart_count", "last_probe_at", + "observation_state", } return {key: value for key, value in status.items() if key in public_fields} - except AppNotFoundError: - async with get_async_db_session() as session: - row = await AppInstanceDao.aget_by_app(session, app.id) - return { - "instance_id": None, - "phase": (row.phase if row is not None else PHASE_STOPPED), - "health": (row.health if row is not None else None), - "current_version_id": app.current_version_id, - "started_at": (row.started_at if row is not None else None), - "restart_count": (row.restart_count if row is not None else 0), - "last_probe_at": (row.last_probe_at if row is not None else None), - } + except AppNotFoundError as exc: + # An older observer's ambiguous 404 proves no current facts. + # Current v2 observers return explicit no_execution/execution_missing. + raise AppOrchestratorUnavailableError(reason="execution_unconfirmed") from exc # -- logs ----------------------------------------------------------- diff --git a/src/backend/bisheng/approval/domain/services/approval_center_service.py b/src/backend/bisheng/approval/domain/services/approval_center_service.py index 32f73e1d98..5cc8934d05 100644 --- a/src/backend/bisheng/approval/domain/services/approval_center_service.py +++ b/src/backend/bisheng/approval/domain/services/approval_center_service.py @@ -16,6 +16,7 @@ from bisheng.approval.domain.schemas.approval_center_schema import ApprovalGateDecision, ApprovalGateRequest from bisheng.approval.domain.services.approval_gate import ApprovalGate from bisheng.approval.domain.services.approval_registry import ApprovalRegistry +from bisheng.approval.domain.services.approval_runtime_handler_factory import read_current_scenario_detail from bisheng.approval.domain.services.menu_access_handler import MenuAccessApprovalHandler from bisheng.approval.domain.services.user_menu_access_service import UserMenuAccessService from bisheng.common.errcode.approval import ( @@ -164,6 +165,7 @@ async def get_task_detail(cls, *, task_id: int, login_user): return { "task_id": task.id, "instance_id": task.instance_id, + **(await read_current_scenario_detail(instance, login_user)), "scenario_code": instance.scenario_code, "scenario_name": instance.scenario_name, "business_name": instance.business_name, @@ -391,6 +393,7 @@ async def get_instance_detail(cls, *, instance_id: int, login_user, management: return { "instance_id": instance.id, + **(await read_current_scenario_detail(instance, login_user, management=management)), "scenario_code": instance.scenario_code, "scenario_name": instance.scenario_name, "business_name": instance.business_name, diff --git a/src/backend/bisheng/approval/domain/services/approval_runtime_handler_factory.py b/src/backend/bisheng/approval/domain/services/approval_runtime_handler_factory.py index d267cd6cdf..6db280d340 100644 --- a/src/backend/bisheng/approval/domain/services/approval_runtime_handler_factory.py +++ b/src/backend/bisheng/approval/domain/services/approval_runtime_handler_factory.py @@ -47,6 +47,18 @@ async def build_runtime_handler(scenario_code: str) -> Any: raise KeyError(f"handler not registered for scenario_code={scenario_code}") +async def read_current_scenario_detail(instance, login_user, *, management: bool = False) -> dict: + """Optional current decoration, called only after the existing read guards.""" + if instance.scenario_code != "app_publish_request": + return {} + handler = await build_runtime_handler(instance.scenario_code) + return { + "current_visibility": await handler.read_current_detail( + instance=instance, login_user=login_user, management=management + ) + } + + class _AsyncSpaceChannelMembershipAdapter: async def find_membership(self, business_id: str, business_type: BusinessTypeEnum, user_id: int): async with get_async_db_session() as session: diff --git a/src/backend/bisheng/common/errcode/knowledge.py b/src/backend/bisheng/common/errcode/knowledge.py index 3b409ecf78..a4f3c69178 100644 --- a/src/backend/bisheng/common/errcode/knowledge.py +++ b/src/backend/bisheng/common/errcode/knowledge.py @@ -236,3 +236,8 @@ class KnowledgeInvalidCursorError(BaseErrorCode): class KnowledgeSpaceListNotSupportedError(BaseErrorCode): Code: int = 10992 Msg: str = "This endpoint does not list knowledge spaces; use the knowledge-space endpoints instead" + + +class KnowledgeCreationScopeChangedError(BaseErrorCode): + Code: int = 10993 + Msg: str = "Your tenant has changed; reopen the knowledge-base creation form" diff --git a/src/backend/bisheng/knowledge/api/endpoints/knowledge.py b/src/backend/bisheng/knowledge/api/endpoints/knowledge.py index 0df7c4eac6..9466155b9e 100644 --- a/src/backend/bisheng/knowledge/api/endpoints/knowledge.py +++ b/src/backend/bisheng/knowledge/api/endpoints/knowledge.py @@ -9,7 +9,6 @@ from fastapi import APIRouter, BackgroundTasks, Body, Depends, File, HTTPException, Query, Request, UploadFile from fastapi.encoders import jsonable_encoder from loguru import logger -from starlette.concurrency import run_in_threadpool from bisheng.api.services import knowledge_imp from bisheng.api.services.knowledge_imp import add_qa @@ -40,6 +39,7 @@ from bisheng.common.services import telemetry_service from bisheng.core.cache.redis_manager import get_redis_client from bisheng.core.cache.utils import save_uploaded_file +from bisheng.core.context.tenant import strict_tenant_filter, tenant_execution_scope from bisheng.core.logger import trace_id_var from bisheng.database.models.role_access import WebMenuResource from bisheng.knowledge.api.dependencies import get_knowledge_file_service, get_knowledge_service @@ -57,6 +57,7 @@ QAKnowledgeUpsert, QAStatus, ) +from bisheng.knowledge.domain.schemas.knowledge_create_schema import KnowledgeWebCreate from bisheng.knowledge.domain.schemas.knowledge_schema import ( AddKnowledgeMetadataFieldsReq, BatchAddFileTagsReq, @@ -64,6 +65,7 @@ UpdateFileTagsReq, UpdateKnowledgeMetadataFieldsReq, ) +from bisheng.knowledge.domain.services.knowledge_creation_policy import KnowledgeCreationPolicy from bisheng.knowledge.domain.services.knowledge_service import KnowledgeService from bisheng.knowledge.domain.upload_file_size import validate_knowledge_upload_file_size from bisheng.llm.domain import LLMService @@ -293,16 +295,14 @@ async def rebuild_knowledge_file( @router.post("/create") async def create_knowledge( - *, request: Request, login_user: UserPayload = Depends(UserPayload.get_login_user), knowledge: KnowledgeCreate + *, request: Request, login_user: UserPayload = Depends(UserPayload.get_login_user), knowledge: KnowledgeWebCreate ): """Create Knowledge Base.""" - await UserPayload.assert_effective_web_menu_contains(login_user.user_id, WebMenuResource.CREATE_KNOWLEDGE.value) - db_knowledge = await run_in_threadpool( - KnowledgeService.create_knowledge, - request, - login_user, - knowledge, - ) + context = await KnowledgeCreationPolicy.prepare(login_user, expected_tenant_id=knowledge.expected_tenant_id) + with tenant_execution_scope(context.tenant_id), strict_tenant_filter(): + db_knowledge = await KnowledgeService.acreate_knowledge( + request, login_user, knowledge, web_creation_context=context + ) return resp_200(db_knowledge) diff --git a/src/backend/bisheng/knowledge/domain/schemas/knowledge_create_schema.py b/src/backend/bisheng/knowledge/domain/schemas/knowledge_create_schema.py new file mode 100644 index 0000000000..520f9f896e --- /dev/null +++ b/src/backend/bisheng/knowledge/domain/schemas/knowledge_create_schema.py @@ -0,0 +1,9 @@ +"""Web-only knowledge creation preconditions; never a target-tenant selector.""" + +from pydantic import Field + +from bisheng.knowledge.domain.models.knowledge import KnowledgeCreate + + +class KnowledgeWebCreate(KnowledgeCreate): + expected_tenant_id: int | None = Field(default=None, gt=0) diff --git a/src/backend/bisheng/knowledge/domain/services/knowledge_creation_policy.py b/src/backend/bisheng/knowledge/domain/services/knowledge_creation_policy.py new file mode 100644 index 0000000000..dec9e63e6a --- /dev/null +++ b/src/backend/bisheng/knowledge/domain/services/knowledge_creation_policy.py @@ -0,0 +1,47 @@ +"""Web-only creation qualification; copy and open APIs retain their contracts.""" + +from copy import deepcopy +from dataclasses import dataclass + +from bisheng.common.dependencies.user_deps import UserPayload +from bisheng.common.errcode.knowledge import KnowledgeCreationScopeChangedError +from bisheng.common.errcode.permission import PermissionDeniedError +from bisheng.database.models.role_access import WebMenuResource +from bisheng.permission.application import is_tenant_admin +from bisheng.user.application.runtime_identity import resolve_web_creation_identity + + +@dataclass(frozen=True, slots=True) +class KnowledgeCreationContext: + user_id: int + tenant_id: int + credential_reference: dict + + +class KnowledgeCreationPolicy: + @classmethod + async def prepare(cls, login_user, *, expected_tenant_id: int | None = None): + tenant_id = login_user.tenant_id + if expected_tenant_id is not None and expected_tenant_id != tenant_id: + raise KnowledgeCreationScopeChangedError() + context = KnowledgeCreationContext( + user_id=login_user.user_id, + tenant_id=tenant_id, + credential_reference=deepcopy(login_user._runtime_credential_reference), + ) + await cls.revalidate(context) + return context + + @staticmethod + async def revalidate(context): + try: + person = await resolve_web_creation_identity(context.credential_reference) + except PermissionDeniedError as exc: + if exc.kwargs.get("reason") == "web_creation_tenant_changed": + raise KnowledgeCreationScopeChangedError() from exc + raise + if person.user_id != context.user_id or person.tenant_id != context.tenant_id: + raise KnowledgeCreationScopeChangedError() + if not person.is_global_super and not await is_tenant_admin(person.user_id, person.tenant_id): + await UserPayload.assert_effective_web_menu_contains(person.user_id, WebMenuResource.CREATE_KNOWLEDGE.value) + return person diff --git a/src/backend/bisheng/knowledge/domain/services/knowledge_service.py b/src/backend/bisheng/knowledge/domain/services/knowledge_service.py index ffe09a8af5..e7bcaf1796 100644 --- a/src/backend/bisheng/knowledge/domain/services/knowledge_service.py +++ b/src/backend/bisheng/knowledge/domain/services/knowledge_service.py @@ -843,7 +843,12 @@ def create_knowledge(cls, request: Request, login_user: UserPayload, knowledge: @classmethod async def acreate_knowledge( - cls, request: Request, login_user: UserPayload, knowledge: KnowledgeCreate + cls, + request: Request, + login_user: UserPayload, + knowledge: KnowledgeCreate, + *, + web_creation_context=None, ) -> Knowledge: from bisheng.llm.domain.share_fallback import aget_model_by_id_with_share_fallback @@ -871,6 +876,10 @@ async def acreate_knowledge( if embed_info.model_type != LLMModelType.EMBEDDING.value: raise KnowledgeNoEmbeddingError.http_exception() + if web_creation_context is not None: + from bisheng.knowledge.domain.services.knowledge_creation_policy import KnowledgeCreationPolicy + + login_user = await KnowledgeCreationPolicy.revalidate(web_creation_context) return await cls.acreate_knowledge_base(request, login_user, db_knowledge) @classmethod diff --git a/src/backend/bisheng/permission/application/control_state.py b/src/backend/bisheng/permission/application/control_state.py index 34d945b04c..677585febf 100644 --- a/src/backend/bisheng/permission/application/control_state.py +++ b/src/backend/bisheng/permission/application/control_state.py @@ -13,6 +13,7 @@ PermissionPublishNotReadyError, PermissionVersionConflictError, ) +from bisheng.core.context.tenant import strict_tenant_filter, tenant_execution_scope from bisheng.core.database import get_async_db_session from bisheng.permission.application.sql_runtime import ( stable_assignee_id, @@ -30,6 +31,7 @@ PermissionVisibleSourceProjection, ResourcePermissionMode, ) +from bisheng.permission.domain.repositories.grant_repository import GrantRepository, ResourcePermissionModeRepository from bisheng.permission.domain.schemas import ( VerifiedPermissionTarget, VisibleSourceProjectionDTO, @@ -455,6 +457,47 @@ async def inherited_grant_set( ), ) + async def source_read_stamp(self, *, target, models): + """Pin every mode and Grant involved in a complete trusted source read.""" + current = (target.resource_type, target.resource_id) + visited, stamp = set(), [] + known = {model.model_key for model in models} + with tenant_execution_scope(target.tenant_id), strict_tenant_filter(): + while current not in visited: + if len(visited) >= 128: + raise PermissionPublishNotReadyError(reason="inheritance_limit") + visited.add(current) + mode = await ResourcePermissionModeRepository().aget_mode( + resource_type=current[0], resource_id=current[1] + ) + if mode is None or mode.tenant_id != target.tenant_id or mode.projection_state != "CURRENT": + raise PermissionPublishNotReadyError(reason="projection_unavailable") + grants = await GrantRepository().alist_resource_grants(resource_type=current[0], resource_id=current[1]) + if len(grants) > 5000: + raise PermissionPublishNotReadyError(reason="summary_limit") + for grant in grants: + if grant.state not in {"ACTIVE", "INACTIVE"} or ( + grant.state == "ACTIVE" + and (grant.projection_state != "CURRENT" or grant.model_key not in known) + ): + raise PermissionPublishNotReadyError(reason="grant_projection_unavailable") + stamp.append( + ( + current, + mode.version, + mode.mode, + mode.parent_type, + mode.parent_id, + tuple(sorted((g.model_key, g.version, g.state, g.projection_state) for g in grants)), + ) + ) + if mode.mode == "CUSTOM": + return tuple(stamp) + if mode.mode != "INHERIT" or mode.parent_type is None or mode.parent_id is None: + break + current = (mode.parent_type, mode.parent_id) + raise PermissionPublishNotReadyError(reason="inheritance_unavailable") + async def load_source_page( self, *, diff --git a/src/backend/bisheng/permission/application/runtime.py b/src/backend/bisheng/permission/application/runtime.py index d90bcc82b2..9032fbed06 100644 --- a/src/backend/bisheng/permission/application/runtime.py +++ b/src/backend/bisheng/permission/application/runtime.py @@ -11,6 +11,7 @@ from bisheng.common.errcode.permission import ( AuthorizationModelMismatchError, InvalidPermissionModeError, + PermissionEnumerationIncompleteError, PermissionInvalidResourceError, PermissionPublishNotReadyError, PermissionVersionConflictError, @@ -1311,6 +1312,42 @@ async def list_permission_sources_page( limit=limit, ) + async def read_effective_action_sources(self, *, target, action): + """Trusted business read after its own qualification; not an HTTP roster. + + The owning business service must verify the target and reader first. + This does not change the manage_permission gate on ordinary rosters. + """ + catalog = await self._runtime_catalog() + models = tuple(item.snapshot for item in catalog.models) + before = await self._state.source_read_stamp(target=target, models=models) + mode = await self._require_current_target(target) + eligible = tuple(model for model in models if model.active and action in model.action_codes) + rows, after_id = [], 0 + while eligible: + page, more = await self._state.load_source_page( + target=target, mode=mode.mode, models=eligible, after_id=after_id, limit=500 + ) + rows.extend(page) + if len(rows) > 5000: + raise PermissionEnumerationIncompleteError(reason="summary_limit") + if not more: + break + next_id = max((row.source_id for row in page), default=after_id) + if next_id <= after_id or len(rows) >= 5000: + raise PermissionEnumerationIncompleteError(reason="source_pagination_incomplete") + after_id = next_id + after = await self._state.source_read_stamp(target=target, models=models) + latest = await self._runtime_catalog() + await self._require_current_target(target) + if before != after or (catalog.release_id, catalog.version, catalog.checksum) != ( + latest.release_id, + latest.version, + latest.checksum, + ): + raise PermissionPublishNotReadyError(reason="summary_scope_changed") + return tuple(rows) + async def list_effective_direct_user_ids_by_model( self, *, diff --git a/src/backend/bisheng/permission/domain/repositories/grant_repository.py b/src/backend/bisheng/permission/domain/repositories/grant_repository.py index b978ca16b6..d9b0b68236 100644 --- a/src/backend/bisheng/permission/domain/repositories/grant_repository.py +++ b/src/backend/bisheng/permission/domain/repositories/grant_repository.py @@ -28,6 +28,24 @@ class GrantRepository( _RepositorySessionMixin, PermissionGrantRepositoryPort, ): + async def alist_resource_grants(self, *, resource_type, resource_id): + async with self._session() as session: + rows = ( + ( + await session.execute( + select(PermissionGrant) + .where( + PermissionGrant.resource_type == resource_type, + PermissionGrant.resource_id == resource_id, + ) + .limit(5001) + ) + ) + .scalars() + .all() + ) + return list(rows) + def __init__( self, session_factory: SessionFactory = get_async_db_session, @@ -257,8 +275,7 @@ async def alist_current_resources( ) -> list[ResourcePermissionMode]: async with self._session() as session: statement = select(ResourcePermissionMode).where( - ResourcePermissionMode.tenant_id == tenant_id, - ResourcePermissionMode.projection_state == "CURRENT" + ResourcePermissionMode.tenant_id == tenant_id, ResourcePermissionMode.projection_state == "CURRENT" ) if resource_type is not None: statement = statement.where(ResourcePermissionMode.resource_type == resource_type) diff --git a/src/backend/bisheng/tenant/domain/repositories/permission_subject_repository.py b/src/backend/bisheng/tenant/domain/repositories/permission_subject_repository.py new file mode 100644 index 0000000000..32f7d344f0 --- /dev/null +++ b/src/backend/bisheng/tenant/domain/repositories/permission_subject_repository.py @@ -0,0 +1,25 @@ +"""Bounded business-owned names for a verified tenant's authorization summary.""" + +from sqlmodel import col, select + +from bisheng.core.context.tenant import strict_tenant_filter, tenant_execution_scope +from bisheng.core.database import get_async_db_session +from bisheng.database.models.department import Department +from bisheng.database.models.group import Group + + +class PermissionSubjectRepository: + async def load(self, tenant_id, subjects): + result = {} + with tenant_execution_scope(tenant_id), strict_tenant_filter(): + async with get_async_db_session() as session: + for kind, model in (("department", Department), ("user_group", Group)): + ids = tuple({int(ident) for typ, ident in subjects if typ == kind}) + for offset in range(0, len(ids), 500): + rows = ( + await session.exec(select(model).where(col(model.id).in_(ids[offset : offset + 500]))) + ).all() + for row in rows: + if row.tenant_id == tenant_id and getattr(row, "status", "active") == "active": + result[(kind, str(row.id))] = row + return result diff --git a/src/backend/bisheng/tenant/domain/services/f048_permission_subject.py b/src/backend/bisheng/tenant/domain/services/f048_permission_subject.py index 8746dc23df..ade1cfd859 100644 --- a/src/backend/bisheng/tenant/domain/services/f048_permission_subject.py +++ b/src/backend/bisheng/tenant/domain/services/f048_permission_subject.py @@ -23,6 +23,8 @@ ) from bisheng.tenant.domain.repositories.grant_department_repository import GrantDepartmentRepository from bisheng.tenant.domain.repositories.grant_subject_search_repository import GrantSubjectSearchRepository +from bisheng.tenant.domain.repositories.permission_subject_repository import PermissionSubjectRepository +from bisheng.user.application.runtime_identity import read_current_person_facts from bisheng.user.domain.models.user import UserDao @@ -33,6 +35,56 @@ def __init__(self) -> None: self._sources = GrantSourceService() self._candidate_queries = GrantSubjectSearchRepository() self._departments = GrantDepartmentRepository() + self._summary_queries = PermissionSubjectRepository() + + async def describe_active_sources(self, *, tenant_id, sources): + """Read names and current identity eligibility, never expand usersets.""" + supported = {"user", "department", "user_group", "service_account"} + for source in sources: + if source.subject_type not in supported or not source.subject_id.isdigit(): + raise PermissionSubjectUnavailableError(reason="unsupported_summary_subject") + keys = tuple({(row.subject_type, row.subject_id) for row in sources}) + users = await read_current_person_facts([int(ident) for kind, ident in keys if kind == "user"]) + names = await self._summary_queries.load(tenant_id, keys) + result, seen = [], set() + for source in sources: + kind, ident = source.subject_type, source.subject_id + if kind == "service_account": + continue + if kind == "user": + person = users.get(int(ident)) + if person is None or person["state"] == "ambiguous": + raise PermissionSubjectUnavailableError(reason="summary_person_unresolved") + if person["state"] != "active" or person["tenant_id"] != tenant_id: + continue + name = person["name"] + else: + row = names.get((kind, ident)) + if row is None or row.tenant_id != tenant_id: + continue + name = row.name if kind == "department" else row.group_name + relation, children = source.userset_relation, source.include_children + if ( + (kind == "user" and (relation is not None or children)) + or (kind == "user_group" and (relation not in {"admin", "member"} or children)) + or (kind == "department" and relation != ("subtree_member" if children else "member")) + ): + raise PermissionSubjectUnavailableError(reason="summary_userset_unresolved") + if not name: + raise PermissionSubjectUnavailableError(reason="summary_name_unresolved") + key = (kind, ident, relation, children) + if key not in seen: + seen.add(key) + result.append( + { + "type": kind, + "id": ident, + "name": name, + "userset_relation": relation, + "include_children": children, + } + ) + return result async def candidate_users(self, scope, *, keyword: str, page: int, page_size: int): return await self._candidate_queries.users(scope, keyword=keyword, page=page, page_size=page_size) diff --git a/src/backend/bisheng/user/application/runtime_identity.py b/src/backend/bisheng/user/application/runtime_identity.py index 8f07bd7c7d..6814f76eb7 100644 --- a/src/backend/bisheng/user/application/runtime_identity.py +++ b/src/backend/bisheng/user/application/runtime_identity.py @@ -1,10 +1,62 @@ """Current verified identity value for server-owned delayed web work.""" -from datetime import datetime +from datetime import UTC, datetime from bisheng.common.dependencies.user_deps import UserPayload from bisheng.common.errcode.permission import PermissionDeniedError -from bisheng.user.domain.repositories.runtime_identity import current_runtime_person +from bisheng.common.services.config_service import settings +from bisheng.user.domain.repositories.runtime_identity import current_person_rows, current_runtime_person + + +async def read_current_person_facts(user_ids): + """Current identity facts, never the request actor or a synthetic session.""" + ids = tuple(dict.fromkeys(int(value) for value in user_ids if str(value).isdigit() and int(value) > 0)) + grouped = {ident: [] for ident in ids} + for user, tenant in await current_person_rows(ids): + if user.user_id in grouped: + grouped[user.user_id].append((user, tenant)) + result = {} + for ident, rows in grouped.items(): + user = rows[0][0] if rows else None + tenants = {tenant for _, tenant in rows if tenant is not None} + if user is None or user.delete != 0: + state, tenant = "inactive", None + elif not settings.multi_tenant.enabled: + state, tenant = "active", 1 + elif len(tenants) == 1: + state, tenant = "active", next(iter(tenants)) + else: + state, tenant = ("ambiguous" if len(tenants) > 1 else "inactive"), None + result[ident] = { + "id": ident, + "name": user.user_name if user else None, + "state": state, + "tenant_id": tenant, + "token_version": user.token_version if user else None, + } + return result + + +async def resolve_web_creation_identity(reference): + try: + expires = datetime.fromisoformat(reference["expires_at"]) + expires = expires.replace(tzinfo=UTC) if expires.tzinfo is None else expires + if reference["kind"] != "web_session" or expires <= datetime.now(UTC): + raise ValueError("expired") + facts = (await read_current_person_facts([reference["user_id"]]))[reference["user_id"]] + if facts["state"] == "active" and facts["tenant_id"] != reference["tenant_id"]: + raise PermissionDeniedError(reason="web_creation_tenant_changed") + if ( + facts["state"] != "active" + or facts["tenant_id"] != reference["tenant_id"] + or facts["token_version"] != reference["token_version"] + ): + raise ValueError("identity changed") + except (KeyError, TypeError, ValueError) as exc: + raise PermissionDeniedError(reason="web_creation_identity_invalid") from exc + return await UserPayload.init_login_user( + user_id=facts["id"], user_name=facts["name"], tenant_id=facts["tenant_id"], token_version=facts["token_version"] + ) async def resolve_web_runtime_identity(reference): diff --git a/src/backend/bisheng/user/domain/repositories/runtime_identity.py b/src/backend/bisheng/user/domain/repositories/runtime_identity.py index 0c09f77204..fc75a3f7ee 100644 --- a/src/backend/bisheng/user/domain/repositories/runtime_identity.py +++ b/src/backend/bisheng/user/domain/repositories/runtime_identity.py @@ -1,6 +1,7 @@ """Exact current person membership for delayed web actions.""" -from sqlmodel import select +from sqlalchemy import and_ +from sqlmodel import col, select from bisheng.core.context.tenant import bypass_tenant_filter from bisheng.core.database import get_async_db_session @@ -8,6 +9,30 @@ from bisheng.user.domain.models.user import User +async def current_person_rows(user_ids): + """Read only the requested people; identity membership can span tenant scopes.""" + identifiers = tuple(dict.fromkeys(user_ids)) + result = [] + with bypass_tenant_filter(): + async with get_async_db_session() as session: + for offset in range(0, len(identifiers), 500): + statement = ( + select(User, Tenant.id) + .outerjoin( + UserTenant, + and_( + UserTenant.user_id == User.user_id, + UserTenant.is_active == 1, + UserTenant.status == "active", + ), + ) + .outerjoin(Tenant, and_(Tenant.id == UserTenant.tenant_id, Tenant.status == "active")) + .where(col(User.user_id).in_(identifiers[offset : offset + 500])) + ) + result.extend((await session.exec(statement)).all()) + return result + + async def current_runtime_person(user_id): with bypass_tenant_filter(): async with get_async_db_session() as session: diff --git a/src/backend/test/app_publish/test_f115_current_visibility.py b/src/backend/test/app_publish/test_f115_current_visibility.py new file mode 100644 index 0000000000..5324f89293 --- /dev/null +++ b/src/backend/test/app_publish/test_f115_current_visibility.py @@ -0,0 +1,125 @@ +"""F115 current approval summary, AC-22 through AC-38 and AC-52.""" + +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + + +@pytest.fixture +def summary(monkeypatch): + from bisheng.app_publish.domain.schemas.current_visibility import ApprovalVisibilityRead + from bisheng.app_publish.domain.services import app_publish_current_visibility_service as module + + app = SimpleNamespace(id="app-a", tenant_id=60, owner_user_id=9, state="draft") + runtime = SimpleNamespace( + read_effective_action_sources=AsyncMock(return_value=()), check_action=AsyncMock(return_value=True) + ) + adapter = SimpleNamespace(resolve_permission_target=AsyncMock(return_value=object())) + directory = SimpleNamespace(describe_active_sources=AsyncMock(return_value=[])) + monkeypatch.setattr(module, "read_application", AsyncMock(return_value=app)) + monkeypatch.setattr(module, "get_f048_runtime", AsyncMock(return_value=runtime)) + monkeypatch.setattr(module, "get_f048_resource_adapter", AsyncMock(return_value=adapter)) + monkeypatch.setattr(module, "get_grant_subject_directory", AsyncMock(return_value=directory)) + monkeypatch.setattr( + module, + "read_current_person_facts", + AsyncMock( + return_value={ + 9: { + "id": 9, + "name": "Owner", + "state": "active", + "tenant_id": 60, + "token_version": 1, + } + } + ), + ) + monkeypatch.setattr(module, "is_tenant_admin", AsyncMock(return_value=False)) + monkeypatch.setattr( + module.UserPayload, + "init_login_user", + AsyncMock(return_value=SimpleNamespace(user_id=9, tenant_id=60, is_global_super=False)), + ) + context = ApprovalVisibilityRead(instance_id=1, tenant_id=60, app_id="app-a", viewer_user_id=2) + viewer = SimpleNamespace(user_id=2, tenant_id=60, is_global_super=True) + return module, context, viewer, runtime, directory, app + + +async def test_success_confirms_owner_only_without_granting_reader_management(summary): + module, context, viewer, runtime, _, _ = summary + result = await module.AppPublishCurrentVisibilityService.read(context, viewer) + assert result["state"] == "available" and result["owner_only"] is True + actor = runtime.check_action.call_args.args[0] + assert actor.user_id == 9 and not actor.super_admin + + +async def test_multiple_userset_objects_are_current_and_typed(summary): + module, context, viewer, _, directory, _ = summary + directory.describe_active_sources.return_value = [ + {"type": "department", "id": "3", "name": "Dept", "include_children": True} + ] + result = await module.AppPublishCurrentVisibilityService.read(context, viewer) + assert result["owner_only"] is False + assert any(row["type"] == "department" for row in result["subjects"]) + + +async def test_unavailable_directory_never_means_owner_only(summary): + module, context, viewer, _, directory, _ = summary + directory.describe_active_sources.side_effect = RuntimeError("test directory outage") + result = await module.AppPublishCurrentVisibilityService.read(context, viewer) + assert result["state"] == "unavailable" and result["owner_only"] is None + assert result["subjects"] == [] and "directory outage" not in str(result) + + +async def test_deleted_app_keeps_no_current_authorization_claim(summary, monkeypatch): + module, context, viewer, runtime, _, _ = summary + monkeypatch.setattr(module, "read_application", AsyncMock(return_value=None)) + result = await module.AppPublishCurrentVisibilityService.read(context, viewer) + assert result["state"] == "deleted" and result["owner_only"] is None + runtime.read_effective_action_sources.assert_not_called() + + +async def test_foreign_application_is_refused_before_directory(summary): + from bisheng.common.errcode.approval import ApprovalRequestPermissionDeniedError + + module, context, viewer, runtime, _, app = summary + app.tenant_id = 61 + with pytest.raises(ApprovalRequestPermissionDeniedError): + await module.AppPublishCurrentVisibilityService.read(context, viewer) + runtime.read_effective_action_sources.assert_not_called() + + +async def test_foreign_deleted_row_does_not_bypass_tenant_binding(summary): + from bisheng.common.errcode.approval import ApprovalRequestPermissionDeniedError + + module, context, viewer, runtime, _, app = summary + app.tenant_id, app.state = 61, "deleted" + with pytest.raises(ApprovalRequestPermissionDeniedError): + await module.AppPublishCurrentVisibilityService.read(context, viewer) + runtime.read_effective_action_sources.assert_not_called() + + +async def test_ineligible_owner_is_not_visible_even_if_name_remains(summary): + module, context, viewer, runtime, _, _ = summary + runtime.check_action.return_value = False + result = await module.AppPublishCurrentVisibilityService.read(context, viewer) + assert result["state"] == "available" and result["owner_only"] is False + assert result["subjects"] == [] and result["owner"]["eligible"] is False + + +async def test_ordinary_approver_does_not_borrow_owner_or_admin_actor(summary): + module, context, viewer, runtime, _, _ = summary + viewer.is_global_super = False + result = await module.AppPublishCurrentVisibilityService.read(context, viewer) + assert result["state"] == "available" + runtime.check_action.assert_awaited_once() + assert runtime.check_action.call_args.args[0].user_id == 9 + + +async def test_grant_edit_during_identity_read_refuses_partial_summary(summary): + module, context, viewer, runtime, _, _ = summary + runtime.read_effective_action_sources.side_effect = [(), (object(),)] + result = await module.AppPublishCurrentVisibilityService.read(context, viewer) + assert result["state"] == "unavailable" and result["owner_only"] is None diff --git a/src/backend/test/app_runtime/test_f115_instance_observation.py b/src/backend/test/app_runtime/test_f115_instance_observation.py new file mode 100644 index 0000000000..e127431e2b --- /dev/null +++ b/src/backend/test/app_runtime/test_f115_instance_observation.py @@ -0,0 +1,51 @@ +"""F115 AC-01/04/06/08/09/10/52: never report historical execution facts.""" + +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + + +@pytest.fixture +def query(monkeypatch): + from bisheng.app_runtime.domain.services import app_query_service as module + + monkeypatch.setattr(module.AppQueryService, "_load_visible", AsyncMock(return_value=SimpleNamespace(id="app-a"))) + return module + + +async def test_live_nullable_facts_and_public_whitelist(query, monkeypatch): + facts = { + "instance_id": "exec-a", + "phase": "running", + "health": None, + "restart_count": None, + "started_at": None, + "observation_state": "current", + "private_environment": "not-public", + } + monkeypatch.setattr(query.orchestrator_client, "status", AsyncMock(return_value=facts)) + result = await query.AppQueryService.get_instance("app-a", actor=object()) + assert result["restart_count"] is None and result["observation_state"] == "current" + assert "private_environment" not in result + + +async def test_ambiguous_old_manager_404_does_not_read_historical_instance(query, monkeypatch): + from bisheng.common.errcode.app_factory import AppNotFoundError, AppOrchestratorUnavailableError + + monkeypatch.setattr(query.orchestrator_client, "status", AsyncMock(side_effect=AppNotFoundError())) + + def no_history(): + pytest.fail("historical instance fallback attempted") + + monkeypatch.setattr(query, "get_async_db_session", no_history) + with pytest.raises(AppOrchestratorUnavailableError): + await query.AppQueryService.get_instance("app-a", actor=object()) + + +async def test_observer_failure_is_not_no_execution(query, monkeypatch): + from bisheng.common.errcode.app_factory import AppOrchestratorUnavailableError + + monkeypatch.setattr(query.orchestrator_client, "status", AsyncMock(side_effect=AppOrchestratorUnavailableError())) + with pytest.raises(AppOrchestratorUnavailableError): + await query.AppQueryService.get_instance("app-a", actor=object()) diff --git a/src/backend/test/approval/test_f115_current_detail.py b/src/backend/test/approval/test_f115_current_detail.py new file mode 100644 index 0000000000..ebf2902b0b --- /dev/null +++ b/src/backend/test/approval/test_f115_current_detail.py @@ -0,0 +1,100 @@ +"""F115 AC-22/30/31/32/33/34/35/36/37/38/52: decoration follows qualification.""" + +from copy import deepcopy +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + + +@pytest.fixture +def detail(monkeypatch): + from bisheng.approval.domain.services import approval_center_service as module + + instance = SimpleNamespace( + id=1, + tenant_id=60, + scenario_code="app_publish_request", + scenario_name="Publish", + business_name="App", + applicant_user_id=9, + applicant_user_name="Owner", + applicant_department_id=None, + flow_version_id=None, + status="pending", + current_node_name=None, + detail_snapshot={"version_no": 1, "visibility_snapshot": []}, + payload_snapshot={"app_id": "app-a"}, + reason=None, + create_time=None, + update_time=None, + ) + task = SimpleNamespace( + id=2, instance_id=1, approver_user_id=3, status="pending", node_name="Review", comment=None, update_time=None + ) + repository = module.ApprovalInstanceRepository + for method, value in (("get_task", task), ("get_instance", instance), ("list_tasks", []), ("list_action_logs", [])): + monkeypatch.setattr(repository, method, AsyncMock(return_value=value)) + current = AsyncMock(return_value={"current_visibility": {"state": "available", "owner_only": True}}) + monkeypatch.setattr(module, "read_current_scenario_detail", current) + viewer = SimpleNamespace(user_id=3, tenant_id=60, is_admin=lambda: False) + return module, instance, viewer, current + + +async def test_authorized_task_gets_current_decoration_without_changing_snapshot(detail): + module, instance, viewer, current = detail + frozen = deepcopy(instance.detail_snapshot) + result = await module.ApprovalCenterService.get_task_detail(task_id=2, login_user=viewer) + assert result["current_visibility"]["owner_only"] is True + assert result["detail_snapshot"] == frozen == instance.detail_snapshot + current.assert_awaited_once() + + +async def test_tenant_denial_precedes_current_scope_read(detail): + from bisheng.common.errcode.approval import ApprovalRequestPermissionDeniedError + + module, _, viewer, current = detail + viewer.tenant_id = 61 + with pytest.raises(ApprovalRequestPermissionDeniedError): + await module.ApprovalCenterService.get_task_detail(task_id=2, login_user=viewer) + current.assert_not_called() + + +async def test_personal_instance_uses_same_current_decoration(detail): + module, _, viewer, current = detail + viewer.user_id = 9 + result = await module.ApprovalCenterService.get_instance_detail(instance_id=1, login_user=viewer) + assert result["current_visibility"]["state"] == "available" + assert current.call_args.kwargs["management"] is False + + +async def test_management_reader_can_read_current_scope_without_app_management(detail, monkeypatch): + from bisheng.permission import application + + module, _, viewer, current = detail + monkeypatch.setattr(application, "is_tenant_admin", AsyncMock(return_value=True)) + result = await module.ApprovalCenterService.get_instance_detail(instance_id=1, login_user=viewer, management=True) + assert result["current_visibility"]["state"] == "available" + assert current.call_args.kwargs["management"] is True + + +async def test_nonparticipant_instance_never_queries_current_roster(detail): + from bisheng.common.errcode.approval import ApprovalRequestPermissionDeniedError + + module, _, viewer, current = detail + with pytest.raises(ApprovalRequestPermissionDeniedError): + await module.ApprovalCenterService.get_instance_detail(instance_id=1, login_user=viewer) + current.assert_not_called() + + +@pytest.mark.parametrize( + "scenario", ["menu_access_request", "channel_subscribe_request", "knowledge_space_subscribe_request"] +) +async def test_other_scenarios_do_not_create_dynamic_queries(scenario, monkeypatch): + from bisheng.approval.domain.services import approval_runtime_handler_factory as factory + + builder = AsyncMock() + monkeypatch.setattr(factory, "build_runtime_handler", builder) + result = await factory.read_current_scenario_detail(SimpleNamespace(scenario_code=scenario), object()) + assert result == {} + builder.assert_not_called() diff --git a/src/backend/test/e2e/helpers/cleanup.py b/src/backend/test/e2e/helpers/cleanup.py index acf4b7f1f0..640fbba8c4 100644 --- a/src/backend/test/e2e/helpers/cleanup.py +++ b/src/backend/test/e2e/helpers/cleanup.py @@ -10,6 +10,48 @@ from test.e2e.helpers.auth import auth_headers +async def cleanup_exact_test_library(client, headers, *, name, kind, tenant_id, owner_id): + """Delete only exact-name, owner/tenant-verified F115 fixtures; fail closed.""" + if not name.startswith("e2e-f115-") or len(name) < 16: + raise ValueError("Only dedicated F115 knowledge fixtures can be cleaned") + cursor = None + for _ in range(20): + page = assert_resp_200( + await client.get( + f"{API_BASE}/knowledge", + headers=headers, + params={ + "name": name, + "type": kind, + "action": "visible", + "page_size": 100, + **({"cursor": cursor} if cursor else {}), + }, + ) + ) + for row in page["data"]: + if row["name"] != name: + continue + facts = assert_resp_200( + await client.get(f"{API_BASE}/knowledge/info", headers=headers, params={"knowledge_id": row["id"]}) + ) + match = next(item for item in facts if item["id"] == row["id"]) + if match.get("tenant_id") != tenant_id or match.get("user_id") != owner_id or match["name"] != name: + raise ValueError("Knowledge cleanup scope mismatch") + assert_resp_200( + await client.request( + "DELETE", f"{API_BASE}/knowledge/", headers=headers, json={"knowledge_id": row["id"]} + ) + ) + if not page["has_more"]: + return + next_cursor = page["next_cursor"] + if not next_cursor or next_cursor == cursor: + raise ValueError("Knowledge cleanup pagination incomplete") + cursor = next_cursor + raise ValueError("Knowledge cleanup pagination limit exceeded") + + async def cleanup_by_prefix( client: httpx.AsyncClient, path: str, @@ -29,35 +71,35 @@ async def cleanup_by_prefix( if len(prefix) < 5: raise ValueError( f'Cleanup prefix too short ({len(prefix)} chars): "{prefix}". ' - f'Must be >= 5 chars to prevent accidental deletion.' + f"Must be >= 5 chars to prevent accidental deletion." ) headers = auth_headers(token) try: resp = await client.get( - f'{API_BASE}{path}', - params={'page': 1, 'limit': 100}, + f"{API_BASE}{path}", + params={"page": 1, "limit": 100}, headers=headers, ) if resp.status_code != 200: return body = resp.json() - if body.get('status_code') != 200: + if body.get("status_code") != 200: return - data = body.get('data', {}) - items = data.get('data', []) if isinstance(data, dict) else data + data = body.get("data", {}) + items = data.get("data", []) if isinstance(data, dict) else data if not isinstance(items, list): return for item in items: - name = item.get('name', '') + name = item.get("name", "") if name.startswith(prefix): - item_id = item.get('id') + item_id = item.get("id") if item_id: await client.delete( - f'{API_BASE}{path}/{item_id}', + f"{API_BASE}{path}/{item_id}", headers=headers, ) except Exception: @@ -79,35 +121,35 @@ async def ensure_test_tenant( # Check if tenant already exists via list API resp = await client.get( - f'{API_BASE}/tenants/', - params={'keyword': tenant_code, 'page': 1, 'page_size': 10}, + f"{API_BASE}/tenants/", + params={"keyword": tenant_code, "page": 1, "page_size": 10}, headers=headers, ) if resp.status_code == 200: body = resp.json() - if body.get('status_code') == 200: - data = body.get('data', {}) - items = data.get('data', []) if isinstance(data, dict) else [] + if body.get("status_code") == 200: + data = body.get("data", {}) + items = data.get("data", []) if isinstance(data, dict) else [] for item in items: - if item.get('tenant_code') == tenant_code: + if item.get("tenant_code") == tenant_code: return item # Not found — create it resp = await client.post( - f'{API_BASE}/tenants/', + f"{API_BASE}/tenants/", json={ - 'tenant_name': f'Test Tenant ({tenant_code})', - 'tenant_code': tenant_code, - 'admin_user_ids': admin_user_ids or [1], + "tenant_name": f"Test Tenant ({tenant_code})", + "tenant_code": tenant_code, + "admin_user_ids": admin_user_ids or [1], }, headers=headers, ) if resp.status_code == 200: body = resp.json() - if body.get('status_code') == 200: - return body.get('data', {}) + if body.get("status_code") == 200: + return body.get("data", {}) - return {'id': None, 'tenant_code': tenant_code} + return {"id": None, "tenant_code": tenant_code} async def cleanup_test_tenants( @@ -122,51 +164,51 @@ async def cleanup_test_tenants( headers = auth_headers(token) try: resp = await client.get( - f'{API_BASE}/tenants/', - params={'page': 1, 'page_size': 100}, + f"{API_BASE}/tenants/", + params={"page": 1, "page_size": 100}, headers=headers, ) if resp.status_code != 200: return body = resp.json() - if body.get('status_code') != 200: + if body.get("status_code") != 200: return - data = body.get('data', {}) - items = data.get('data', []) if isinstance(data, dict) else [] + data = body.get("data", {}) + items = data.get("data", []) if isinstance(data, dict) else [] for item in items: - code = item.get('tenant_code', '') + code = item.get("tenant_code", "") if code.startswith(prefix): - tid = item.get('id') + tid = item.get("id") if not tid: continue # Remove all users first (so delete succeeds) users_resp = await client.get( - f'{API_BASE}/tenants/{tid}/users', - params={'page': 1, 'page_size': 100}, + f"{API_BASE}/tenants/{tid}/users", + params={"page": 1, "page_size": 100}, headers=headers, ) if users_resp.status_code == 200: users_body = users_resp.json() - if users_body.get('status_code') == 200: - users_data = users_body.get('data', {}) - user_list = users_data.get('data', []) if isinstance(users_data, dict) else [] + if users_body.get("status_code") == 200: + users_data = users_body.get("data", {}) + user_list = users_data.get("data", []) if isinstance(users_data, dict) else [] for u in user_list: - uid = u.get('user_id') + uid = u.get("user_id") if uid: await client.delete( - f'{API_BASE}/tenants/{tid}/users/{uid}', + f"{API_BASE}/tenants/{tid}/users/{uid}", headers=headers, ) # Disable then delete await client.put( - f'{API_BASE}/tenants/{tid}/status', - json={'status': 'disabled'}, + f"{API_BASE}/tenants/{tid}/status", + json={"status": "disabled"}, headers=headers, ) await client.delete( - f'{API_BASE}/tenants/{tid}', + f"{API_BASE}/tenants/{tid}", headers=headers, ) except Exception: diff --git a/src/backend/test/e2e/test_e2e_f115_runtime_review.py b/src/backend/test/e2e/test_e2e_f115_runtime_review.py new file mode 100644 index 0000000000..6a7ed8d85f --- /dev/null +++ b/src/backend/test/e2e/test_e2e_f115_runtime_review.py @@ -0,0 +1,140 @@ +"""Opt-in F115 journeys against a deployed platform, never mocks. + +Use only dedicated e2e-f115 people/app/tenant fixtures. Credentials are supplied +in environment variables and remain in memory. The selected management scope +must already match F115_E2E_TENANT; this suite never changes a global scope. +Creation writes are disabled unless F115_E2E_ALLOW_CREATE=1 is also explicit. +""" + +import os +from dataclasses import dataclass, field + +import httpx +import pytest + +from test.e2e.helpers.api import API_BASE, assert_resp_200, assert_resp_error +from test.e2e.helpers.auth import auth_headers +from test.e2e.helpers.cleanup import cleanup_exact_test_library + +pytestmark = pytest.mark.skipif( + os.environ.get("F115_E2E") != "1", reason="requires deployed F115 and dedicated fixtures" +) + + +@dataclass +class Target: + client: httpx.AsyncClient = field(repr=False) + reader: dict = field(repr=False) + foreign: dict = field(repr=False) + tenant_admin: dict = field(repr=False) + ordinary: dict = field(repr=False) + tenant_id: int + app_id: str + task_id: int + instance_id: int + + +@pytest.fixture +async def target(): + async with httpx.AsyncClient(timeout=40, trust_env=False, follow_redirects=False) as client: + result = Target( + client=client, + reader=auth_headers(os.environ["F115_E2E_APPROVER_TOKEN"]), + foreign=auth_headers(os.environ["F115_E2E_FOREIGN_TOKEN"]), + tenant_admin=auth_headers(os.environ["F115_E2E_TENANT_ADMIN_TOKEN"]), + ordinary=auth_headers(os.environ["F115_E2E_NO_CREATE_TOKEN"]), + tenant_id=int(os.environ["F115_E2E_TENANT"]), + app_id=os.environ["F115_E2E_APP_ID"], + task_id=int(os.environ["F115_E2E_TASK_ID"]), + instance_id=int(os.environ["F115_E2E_INSTANCE_ID"]), + ) + for headers in (result.reader, result.foreign, result.tenant_admin, result.ordinary): + identity = assert_resp_200(await client.get(f"{API_BASE}/user/info", headers=headers)) + assert identity["user_name"].startswith("e2e-f115-") + if headers == result.ordinary: + assert identity.get("role") != "admin" and not identity.get("is_child_admin") + assert "create_knowledge" not in identity.get("web_menu", []) + if headers == result.tenant_admin: + assert identity.get("is_child_admin") and identity.get("leaf_tenant_id") == result.tenant_id + app = assert_resp_200(await client.get(f"{API_BASE}/apps/{result.app_id}", headers=result.tenant_admin)) + assert app["tenant_id"] == result.tenant_id and app["name"].startswith("e2e-f115-") + yield result + + +async def test_instance_is_current_and_nullable(target): + """AC-01, AC-02, AC-03, AC-04, AC-05, AC-06, AC-07, AC-08, AC-09, AC-10, AC-52.""" + data = assert_resp_200( + await target.client.get(f"{API_BASE}/apps/{target.app_id}/instance", headers=target.tenant_admin) + ) + assert data["observation_state"] in {"current", "no_execution", "execution_missing"} + count = data["restart_count"] + assert count is None or (type(count) is int and count >= 0) + if data["observation_state"] != "current": + assert data["started_at"] is None and count is None + + +async def test_approval_current_scope_and_foreign_denial(target): + """AC-22, AC-23, AC-30, AC-31, AC-32, AC-33, AC-34, AC-35, AC-36, AC-37, AC-38, AC-52.""" + paths = (f"/approval/my-tasks/{target.task_id}", f"/approval/instances/{target.instance_id}") + for path in paths: + data = assert_resp_200(await target.client.get(API_BASE + path, headers=target.reader)) + assert data["current_visibility"]["state"] == "available" + assert "detail_snapshot" in data and "payload_snapshot" in data + assert_resp_error(await target.client.get(API_BASE + path, headers=target.foreign), 18101) + + +async def test_model_candidates_preserve_real_people(target): + """AC-11, AC-13, AC-14, AC-15, AC-16, AC-52: a prepared real-person call is selectable.""" + data = assert_resp_200( + await target.client.get( + f"{API_BASE}/app-runtime-audit/candidates/visitor", + headers=target.tenant_admin, + params={"kind": "model", "app_id": target.app_id, "time_range": "all", "tenant_id": target.tenant_id}, + ) + ) + assert all(str(item["id"]).isdigit() and item["label"] for item in data["items"]) + assert any(str(item["id"]) == os.environ["F115_E2E_VISITOR_ID"] for item in data["items"]) + + +@pytest.mark.parametrize("kind", [0, 1]) +async def test_tenant_admin_creation_and_old_form_denial(target, kind): + """AC-39, AC-40, AC-42, AC-43, AC-45, AC-52. + + Remaining role-change/copy pairings are explicit in the UI/operator checklist. + """ + if os.environ.get("F115_E2E_ALLOW_CREATE") != "1": + pytest.skip("knowledge creation requires explicit write authorization") + name = f"e2e-f115-kb-{target.tenant_id}-{kind}" + owner_id = int(os.environ["F115_E2E_TENANT_ADMIN_ID"]) + await cleanup_exact_test_library( + target.client, target.tenant_admin, name=name, kind=kind, tenant_id=target.tenant_id, owner_id=owner_id + ) + payload = { + "name": name, + "type": kind, + "model": os.environ["F115_E2E_EMBEDDING_ID"], + "expected_tenant_id": target.tenant_id, + } + # Both rejection checks precede the only write; no historical resource is touched. + wrong = {**payload, "expected_tenant_id": int(os.environ["F115_E2E_FOREIGN_TENANT"])} + assert_resp_error( + await target.client.post(f"{API_BASE}/knowledge/create", headers=target.tenant_admin, json=wrong), 10993 + ) + assert_resp_error( + await target.client.post(f"{API_BASE}/knowledge/create", headers=target.ordinary, json=payload), 403 + ) + data = assert_resp_200( + await target.client.post(f"{API_BASE}/knowledge/create", headers=target.tenant_admin, json=payload) + ) + try: + assert data["tenant_id"] == target.tenant_id and data["name"] == name and data["user_id"] == owner_id + rows = assert_resp_200( + await target.client.get( + f"{API_BASE}/knowledge/info", headers=target.tenant_admin, params={"knowledge_id": data["id"]} + ) + ) + assert any(row["id"] == data["id"] and row["name"] == name for row in rows) + finally: + await cleanup_exact_test_library( + target.client, target.tenant_admin, name=name, kind=kind, tenant_id=target.tenant_id, owner_id=owner_id + ) diff --git a/src/backend/test/knowledge/test_f115_creation_entry.py b/src/backend/test/knowledge/test_f115_creation_entry.py new file mode 100644 index 0000000000..3ca027fe2f --- /dev/null +++ b/src/backend/test/knowledge/test_f115_creation_entry.py @@ -0,0 +1,57 @@ +"""F115 AC-41/42/44/47/49/50/52: pre-write revalidation, not copy.""" + +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + + +async def test_web_entry_fixes_scope_to_identity_and_passes_context(monkeypatch): + from bisheng.core.context.tenant import get_current_tenant_id + from bisheng.knowledge.api.endpoints import knowledge as api + from bisheng.knowledge.domain.schemas.knowledge_create_schema import KnowledgeWebCreate + from bisheng.knowledge.domain.services.knowledge_creation_policy import KnowledgeCreationPolicy + + context = SimpleNamespace(user_id=9, tenant_id=60) + monkeypatch.setattr(KnowledgeCreationPolicy, "prepare", AsyncMock(return_value=context)) + + async def create(request, user, data, *, web_creation_context=None): + assert get_current_tenant_id() == 60 + assert web_creation_context is context + return {"id": 1, "tenant_id": 60, "user_id": user.user_id} + + monkeypatch.setattr(api.KnowledgeService, "acreate_knowledge", create) + result = await api.create_knowledge( + request=object(), + login_user=SimpleNamespace(user_id=9, tenant_id=60), + knowledge=KnowledgeWebCreate(name="New library", model="1", type=0, expected_tenant_id=60), + ) + assert result.data["tenant_id"] == 60 + + +async def test_revocation_during_model_lookup_prevents_all_creation_writes(monkeypatch): + from bisheng.common.errcode.permission import PermissionDeniedError + from bisheng.knowledge.domain.models.knowledge import KnowledgeCreate + from bisheng.knowledge.domain.services.knowledge_creation_policy import KnowledgeCreationPolicy + from bisheng.knowledge.domain.services.knowledge_service import KnowledgeDao, KnowledgeService, LLMModelType + from bisheng.llm.domain import share_fallback + + monkeypatch.setattr(KnowledgeDao, "aget_knowledge_by_name", AsyncMock(return_value=None)) + monkeypatch.setattr( + share_fallback, + "aget_model_by_id_with_share_fallback", + AsyncMock(return_value=SimpleNamespace(model_type=LLMModelType.EMBEDDING.value)), + ) + check = AsyncMock(side_effect=PermissionDeniedError()) + monkeypatch.setattr(KnowledgeCreationPolicy, "revalidate", check) + write = AsyncMock() + monkeypatch.setattr(KnowledgeService, "acreate_knowledge_base", write) + with pytest.raises(PermissionDeniedError): + await KnowledgeService.acreate_knowledge( + object(), + SimpleNamespace(user_id=9, tenant_id=60), + KnowledgeCreate(name="Test", model="1", type=0), + web_creation_context=object(), + ) + check.assert_awaited_once() + write.assert_not_called() diff --git a/src/backend/test/knowledge/test_f115_creation_policy.py b/src/backend/test/knowledge/test_f115_creation_policy.py new file mode 100644 index 0000000000..f162e5cbcc --- /dev/null +++ b/src/backend/test/knowledge/test_f115_creation_policy.py @@ -0,0 +1,66 @@ +"""F115 AC-39 through AC-50 and AC-52: new creation is not copy permission.""" + +from datetime import datetime, timedelta +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + + +@pytest.fixture +def policy(monkeypatch): + from bisheng.knowledge.domain.services import knowledge_creation_policy as module + + ref = { + "kind": "web_session", + "user_id": 9, + "tenant_id": 60, + "token_version": 1, + "expires_at": (datetime.utcnow() + timedelta(minutes=2)).isoformat(), + } + viewer = SimpleNamespace(user_id=9, tenant_id=60, _runtime_credential_reference=ref) + person = SimpleNamespace(user_id=9, tenant_id=60, is_global_super=False) + monkeypatch.setattr(module, "resolve_web_creation_identity", AsyncMock(return_value=person)) + monkeypatch.setattr(module, "is_tenant_admin", AsyncMock(return_value=True)) + monkeypatch.setattr( + module.UserPayload, + "assert_effective_web_menu_contains", + AsyncMock(side_effect=AssertionError("menu must not be required")), + ) + return module, viewer + + +async def test_tenant_admin_can_create_without_a_menu(policy): + module, viewer = policy + context = await module.KnowledgeCreationPolicy.prepare(viewer, expected_tenant_id=60) + assert context.tenant_id == 60 and context.user_id == 9 + + +async def test_foreign_expected_tenant_does_not_select_a_write_target(policy): + from bisheng.common.errcode.knowledge import KnowledgeCreationScopeChangedError + + module, viewer = policy + with pytest.raises(KnowledgeCreationScopeChangedError): + await module.KnowledgeCreationPolicy.prepare(viewer, expected_tenant_id=61) + + +async def test_revoked_admin_requires_the_original_menu_at_revalidation(policy, monkeypatch): + from bisheng.common.errcode.permission import PermissionDeniedError + + module, viewer = policy + context = await module.KnowledgeCreationPolicy.prepare(viewer, expected_tenant_id=60) + monkeypatch.setattr(module, "is_tenant_admin", AsyncMock(return_value=False)) + monkeypatch.setattr( + module.UserPayload, "assert_effective_web_menu_contains", AsyncMock(side_effect=PermissionDeniedError()) + ) + with pytest.raises(PermissionDeniedError): + await module.KnowledgeCreationPolicy.revalidate(context) + + +async def test_ordinary_user_with_original_menu_remains_allowed(policy, monkeypatch): + module, viewer = policy + monkeypatch.setattr(module, "is_tenant_admin", AsyncMock(return_value=False)) + check = AsyncMock() + monkeypatch.setattr(module.UserPayload, "assert_effective_web_menu_contains", check) + await module.KnowledgeCreationPolicy.prepare(viewer, expected_tenant_id=60) + check.assert_awaited_once() diff --git a/src/backend/test/knowledge/test_f115_e2e_cleanup.py b/src/backend/test/knowledge/test_f115_e2e_cleanup.py new file mode 100644 index 0000000000..0681dc8d2d --- /dev/null +++ b/src/backend/test/knowledge/test_f115_e2e_cleanup.py @@ -0,0 +1,31 @@ +"""F115 test infrastructure must refuse cleanup outside the named fixture.""" + +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import httpx +import pytest + +from test.e2e.helpers.cleanup import cleanup_exact_test_library + + +async def test_cleanup_rejects_a_nontest_name_before_any_http_call(): + client = SimpleNamespace(get=AsyncMock(), request=AsyncMock()) + with pytest.raises(ValueError): + await cleanup_exact_test_library(client, {}, name="Production library", kind=0, tenant_id=60, owner_id=9) + client.get.assert_not_called() + client.request.assert_not_called() + + +async def test_cleanup_does_not_delete_a_foreign_tenant_fixture(): + name = "e2e-f115-kb-60-0" + responses = [ + httpx.Response(200, json={"status_code": 200, "data": {"data": [{"id": 1, "name": name}], "has_more": False}}), + httpx.Response( + 200, json={"status_code": 200, "data": [{"id": 1, "name": name, "tenant_id": 61, "user_id": 9}]} + ), + ] + client = SimpleNamespace(get=AsyncMock(side_effect=responses), request=AsyncMock()) + with pytest.raises(ValueError, match="scope mismatch"): + await cleanup_exact_test_library(client, {}, name=name, kind=0, tenant_id=60, owner_id=9) + client.request.assert_not_called() diff --git a/src/backend/test/permission/test_f115_action_sources.py b/src/backend/test/permission/test_f115_action_sources.py new file mode 100644 index 0000000000..19ec428696 --- /dev/null +++ b/src/backend/test/permission/test_f115_action_sources.py @@ -0,0 +1,100 @@ +"""F115 AC-23/26/27/28/33/34/37/52: trusted action sources, not model names.""" + +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + +from bisheng.permission.application.runtime import F048PermissionRuntime +from bisheng.permission.domain.schemas import VerifiedPermissionTarget +from bisheng.permission.domain.services.grant_source_service import GrantModelSnapshot + + +def runtime(pages): + instance = object.__new__(F048PermissionRuntime) + instance._state = SimpleNamespace( + source_read_stamp=AsyncMock(return_value=(("app", "a", 3),)), + mode_for_target=AsyncMock( + return_value=SimpleNamespace( + mode="CUSTOM", version=3, parent_type=None, parent_id=None, projection_state="CURRENT" + ) + ), + load_source_page=AsyncMock(side_effect=pages), + ) + models = ( + SimpleNamespace(snapshot=GrantModelSnapshot("owner", True, ("edit",))), + SimpleNamespace(snapshot=GrantModelSnapshot("custom", True, ("use",))), + ) + instance._runtime_catalog = AsyncMock( + return_value=SimpleNamespace(release_id=1, version=1, checksum="same", models=models) + ) + target = VerifiedPermissionTarget.from_business_service( + tenant_id=60, resource_type="app", resource_id="a", resource_version=3, context_version="test" + ) + return instance, target + + +async def test_actions_not_model_names_and_all_pages(): + row = SimpleNamespace(source_id=7) + service, target = runtime([((row,), True), ((), False)]) + result = await service.read_effective_action_sources(target=target, action="use") + assert result == (row,) + assert service._state.load_source_page.call_args.kwargs["models"][0].model_key == "custom" + assert service._state.load_source_page.call_args.kwargs["after_id"] == 7 + + +async def test_projection_change_refuses_partial_roster(): + from bisheng.common.errcode.permission import PermissionPublishNotReadyError + + service, target = runtime([((), False)]) + service._state.source_read_stamp.side_effect = [(1,), (2,)] + with pytest.raises(PermissionPublishNotReadyError): + await service.read_effective_action_sources(target=target, action="use") + + +async def test_nonadvancing_page_refuses_empty_owner_only(): + from bisheng.common.errcode.permission import PermissionEnumerationIncompleteError + + service, target = runtime([((), True)]) + with pytest.raises(PermissionEnumerationIncompleteError): + await service.read_effective_action_sources(target=target, action="use") + + +async def test_catalog_change_refuses_old_action_meanings(): + from bisheng.common.errcode.permission import PermissionPublishNotReadyError + + service, target = runtime([((), False)]) + catalog = await service._runtime_catalog() + latest = SimpleNamespace(**{**vars(catalog), "checksum": "new"}) + service._runtime_catalog.side_effect = [catalog, latest] + with pytest.raises(PermissionPublishNotReadyError): + await service.read_effective_action_sources(target=target, action="use") + + +async def test_inherited_source_stamp_checks_every_ancestor(monkeypatch): + from bisheng.common.errcode.permission import PermissionPublishNotReadyError + from bisheng.permission.application import control_state as module + + _service, target = runtime([]) + modes = { + "a": SimpleNamespace( + tenant_id=60, mode="INHERIT", version=3, parent_type="app", parent_id="parent", projection_state="CURRENT" + ), + "parent": SimpleNamespace( + tenant_id=60, mode="CUSTOM", version=4, parent_type=None, parent_id=None, projection_state="CURRENT" + ), + } + + async def get_mode(*, resource_type, resource_id): + return modes[resource_id] + + monkeypatch.setattr(module, "ResourcePermissionModeRepository", lambda: SimpleNamespace(aget_mode=get_mode)) + monkeypatch.setattr( + module, "GrantRepository", lambda: SimpleNamespace(alist_resource_grants=AsyncMock(return_value=[])) + ) + state = object.__new__(module.SqlPermissionControlState) + stamp = await state.source_read_stamp(target=target, models=()) + assert len(stamp) == 2 + modes["parent"].projection_state = "PENDING" + with pytest.raises(PermissionPublishNotReadyError): + await state.source_read_stamp(target=target, models=()) diff --git a/src/backend/test/tenant/test_f115_visibility_subjects.py b/src/backend/test/tenant/test_f115_visibility_subjects.py new file mode 100644 index 0000000000..df86ea8b6a --- /dev/null +++ b/src/backend/test/tenant/test_f115_visibility_subjects.py @@ -0,0 +1,69 @@ +"""F115 AC-23/24/25/27/28/29/37/52: business-owned current subject facts.""" + +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + + +def source(kind, ident, relation=None, children=False): + return SimpleNamespace( + subject_type=kind, subject_id=str(ident), userset_relation=relation, include_children=children + ) + + +async def test_current_directory_filters_inactive_and_keeps_usersets(monkeypatch): + from bisheng.tenant.domain.services import f048_permission_subject as module + + directory = module.TenantPermissionSubjectDirectory() + directory._summary_queries = SimpleNamespace( + load=AsyncMock( + return_value={ + ("department", "3"): SimpleNamespace(tenant_id=60, name="Dept"), + ("user_group", "4"): SimpleNamespace(tenant_id=60, group_name="Group"), + } + ) + ) + monkeypatch.setattr( + module, + "read_current_person_facts", + AsyncMock( + return_value={ + 1: {"state": "active", "tenant_id": 60, "name": "Owner"}, + 2: {"state": "inactive", "tenant_id": None, "name": "Disabled"}, + } + ), + ) + rows = await directory.describe_active_sources( + tenant_id=60, + sources=( + source("user", 1), + source("user", 2), + source("department", 3, "subtree_member", True), + source("user_group", 4, "admin"), + source("service_account", 5), + ), + ) + assert [row["type"] for row in rows] == ["user", "department", "user_group"] + assert rows[-1]["userset_relation"] == "admin" + + +async def test_ambiguous_person_does_not_mean_empty_authorization(monkeypatch): + from bisheng.common.errcode.permission import PermissionSubjectUnavailableError + from bisheng.tenant.domain.services import f048_permission_subject as module + + directory = module.TenantPermissionSubjectDirectory() + directory._summary_queries = SimpleNamespace(load=AsyncMock(return_value={})) + monkeypatch.setattr(module, "read_current_person_facts", AsyncMock(return_value={1: {"state": "ambiguous"}})) + with pytest.raises(PermissionSubjectUnavailableError): + await directory.describe_active_sources(tenant_id=60, sources=(source("user", 1),)) + + +async def test_unknown_tenant_source_cannot_be_ignored(): + from bisheng.common.errcode.permission import PermissionSubjectUnavailableError + from bisheng.tenant.domain.services import f048_permission_subject as module + + directory = module.TenantPermissionSubjectDirectory() + directory._summary_queries = SimpleNamespace(load=AsyncMock(return_value={})) + with pytest.raises(PermissionSubjectUnavailableError): + await directory.describe_active_sources(tenant_id=60, sources=(source("tenant", 60),)) diff --git a/src/backend/test/user/test_f115_current_identity.py b/src/backend/test/user/test_f115_current_identity.py new file mode 100644 index 0000000000..cdc409de2b --- /dev/null +++ b/src/backend/test/user/test_f115_current_identity.py @@ -0,0 +1,68 @@ +"""F115 current-person eligibility for summaries and Web creation.""" + +from datetime import datetime, timedelta +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + + +async def test_person_facts_do_not_use_request_actor(monkeypatch): + from bisheng.user.application import runtime_identity as module + + user = SimpleNamespace(user_id=9, user_name="owner", delete=0, token_version=2) + monkeypatch.setattr(module, "current_person_rows", AsyncMock(return_value=[(user, 60), (user, 61)])) + monkeypatch.setattr(module.settings.multi_tenant, "enabled", True) + facts = await module.read_current_person_facts([9]) + assert facts[9]["state"] == "ambiguous" + + +async def test_disabled_or_missing_person_is_ineligible(monkeypatch): + from bisheng.user.application import runtime_identity as module + + user = SimpleNamespace(user_id=9, user_name="owner", delete=1, token_version=2) + monkeypatch.setattr(module, "current_person_rows", AsyncMock(return_value=[(user, None)])) + monkeypatch.setattr(module.settings.multi_tenant, "enabled", True) + facts = await module.read_current_person_facts([9, 10]) + assert facts[9]["state"] == facts[10]["state"] == "inactive" + + +async def test_single_tenant_person_needs_no_membership_row(monkeypatch): + from bisheng.user.application import runtime_identity as module + + user = SimpleNamespace(user_id=9, user_name="owner", delete=0, token_version=2) + monkeypatch.setattr(module, "current_person_rows", AsyncMock(return_value=[(user, None)])) + monkeypatch.setattr(module.settings.multi_tenant, "enabled", False) + facts = await module.read_current_person_facts([9]) + assert facts[9]["state"] == "active" and facts[9]["tenant_id"] == 1 + + +@pytest.mark.parametrize("tenant,version", [(61, 2), (60, 1)]) +async def test_creation_refuses_changed_tenant_or_session(monkeypatch, tenant, version): + from bisheng.common.errcode.permission import PermissionDeniedError + from bisheng.user.application import runtime_identity as module + + monkeypatch.setattr( + module, + "read_current_person_facts", + AsyncMock( + return_value={ + 9: { + "state": "active", + "tenant_id": 60, + "token_version": 2, + "name": "owner", + "id": 9, + } + } + ), + ) + ref = { + "kind": "web_session", + "user_id": 9, + "tenant_id": tenant, + "token_version": version, + "expires_at": (datetime.utcnow() + timedelta(minutes=2)).isoformat(), + } + with pytest.raises(PermissionDeniedError): + await module.resolve_web_creation_identity(ref) diff --git a/src/frontend/client/src/api/approval.ts b/src/frontend/client/src/api/approval.ts index 7d84475011..049b258312 100644 --- a/src/frontend/client/src/api/approval.ts +++ b/src/frontend/client/src/api/approval.ts @@ -52,7 +52,16 @@ export interface ApprovalInstanceItem { update_time?: string; } +export interface AppCurrentVisibility { + state: 'available' | 'unavailable' | 'deleted'; + checked_at?: string; + owner_only?: boolean | null; + admin_implicit_access?: boolean; + subjects: { type: 'user' | 'department' | 'user_group'; id: string; name: string; userset_relation?: 'member' | 'admin' | 'subtree_member'; include_children?: boolean }[]; +} + export interface ApprovalTaskDetail extends ApprovalTaskItem { + current_visibility?: AppCurrentVisibility; comment?: string | null; reason?: string | null; detail_snapshot?: Record; @@ -77,6 +86,7 @@ export interface ApprovalFlowNode { } export interface ApprovalInstanceDetail extends ApprovalInstanceItem { + current_visibility?: AppCurrentVisibility; reason?: string | null; granted_keys?: string[]; revoked_keys?: string[]; diff --git a/src/frontend/client/src/components/approval/AppPublishDetailPanel.test.tsx b/src/frontend/client/src/components/approval/AppPublishDetailPanel.test.tsx index 2db503cd0d..a25b6e069e 100644 --- a/src/frontend/client/src/components/approval/AppPublishDetailPanel.test.tsx +++ b/src/frontend/client/src/components/approval/AppPublishDetailPanel.test.tsx @@ -5,6 +5,8 @@ import { isAppPublishScenario, } from "./AppPublishDetailPanel"; import type { LocalizeFn } from "./approvalPresentation"; +jest.mock('~/utils', () => ({ cn: (...values: unknown[]) => values.filter(Boolean).join(' ') })); +jest.mock('~/hooks', () => ({ useLocalize: () => (key: string) => key })); /** Echoes the key back so assertions read as the contract, plus the interpolated value. */ const localize: LocalizeFn = (key, options) => @@ -135,7 +137,7 @@ describe("AppPublishDetailPanel", () => { screen.getByText("com_approval_app_publish_capabilities_empty"), ).toBeInTheDocument(); expect( - screen.getByText("com_approval_app_publish_visibility_empty"), + screen.getByText("approval.current_visibility.unavailable"), ).toBeInTheDocument(); // Region 4 — the tier dict expanded into name / cpu / memory. @@ -202,7 +204,7 @@ describe("AppPublishDetailPanel", () => { ).toBeInTheDocument(); }); - it("lists declared capabilities and visibility entries when they arrive", () => { + it("lists frozen capabilities and current visibility instead of the old snapshot", () => { const detail = buildDetail(); detail.detail_snapshot = { ...detail.detail_snapshot, @@ -211,6 +213,7 @@ describe("AppPublishDetailPanel", () => { ], visibility_snapshot: [{ type: "department", name: "R&D" }], }; + detail.current_visibility = { state: 'available', owner_only: false, subjects: [{ type: 'department', id: '12', name: 'Current department', userset_relation: 'member' }] }; const { container } = render( { ); expect(screen.getByText("qwen-max")).toBeInTheDocument(); expect(screen.getByText("model · chat model")).toBeInTheDocument(); - expect(screen.getByText("R&D")).toBeInTheDocument(); + expect(screen.getByText(/Current department/)).toBeInTheDocument(); + expect(screen.queryByText('R&D')).not.toBeInTheDocument(); expect(container.textContent).not.toContain("[object Object]"); }); diff --git a/src/frontend/client/src/components/approval/AppPublishDetailPanel.tsx b/src/frontend/client/src/components/approval/AppPublishDetailPanel.tsx index e0afe5b5b6..3b6f37d2c5 100644 --- a/src/frontend/client/src/components/approval/AppPublishDetailPanel.tsx +++ b/src/frontend/client/src/components/approval/AppPublishDetailPanel.tsx @@ -4,6 +4,7 @@ import type { } from "~/api/approval"; import { cn } from "~/utils"; import type { AppReviewTarget } from "./AppReviewView"; +import { CurrentAppVisibility } from './CurrentAppVisibility'; import { DetailHeader, formatSerialNo, @@ -26,12 +27,6 @@ type CapabilityEntry = { description: string; }; -/** One visibility grant in the release's visibility snapshot (owned by F056). */ -type VisibilityEntry = { - type: string; - name: string; -}; - /** One row of the structure change (`schema_change.items[]`, F055 AC-09). */ type SchemaChangeEntry = { op: string; @@ -59,6 +54,7 @@ export interface AppPublishDetailPanelProps { scope: "task" | "instance"; localize: LocalizeFn; onBack?: () => void; + onRefresh?: () => void; /** * Opens the review view on the version waiting to go live (AC-24's third * operation, AC-25). Omitted — or called with a release whose snapshot the @@ -108,19 +104,6 @@ function toCapabilityEntries( }); } -function toVisibilityEntries(value: unknown): VisibilityEntry[] { - if (!Array.isArray(value)) return []; - return value.map((item) => { - if (typeof item === "string") return { type: "", name: item }; - const record = asRecord(item); - return { - type: asText(record.type) || asText(record.subject_type), - name: - asText(record.name) || asText(record.subject_name) || asText(record.id), - }; - }); -} - function toSchemaChangeEntries(value: unknown): SchemaChangeEntry[] { const items = asRecord(value).items; if (!Array.isArray(items)) return []; @@ -170,6 +153,7 @@ export function AppPublishDetailPanel({ scope, localize, onBack, + onRefresh, onOpenReview, }: AppPublishDetailPanelProps) { const snapshot: Record = detail.detail_snapshot ?? {}; @@ -234,7 +218,6 @@ export function AppPublishDetailPanel({ : approverNote; const capabilities = toCapabilityEntries(snapshot.capabilities, localize); - const visibility = toVisibilityEntries(snapshot.visibility_snapshot); // AC-09 / AC-24: the "结构变更" row appears only when the release changes the // declared tables. Breaking rows were confirmed by the publisher at submit // time — the approver sees what was confirmed, they are not asked again. @@ -364,28 +347,7 @@ export function AppPublishDetailPanel({ )} - {/* ③ Visibility snapshot */} -
- - {localize("com_approval_app_publish_section_visibility")} - - {visibility.length === 0 ? ( - - {localize("com_approval_app_publish_visibility_empty")} - - ) : ( -
- {visibility.map((entry, index) => ( - - {entry.name || "--"} - - ))} -
- )} -
+ {/* ④ Resource tier (+ the structure-change row when there is one) */}
diff --git a/src/frontend/client/src/components/approval/ApprovalDetailPanels.tsx b/src/frontend/client/src/components/approval/ApprovalDetailPanels.tsx index 9cb5a2c993..be685d1a3e 100644 --- a/src/frontend/client/src/components/approval/ApprovalDetailPanels.tsx +++ b/src/frontend/client/src/components/approval/ApprovalDetailPanels.tsx @@ -29,7 +29,7 @@ type TimelineNode = { status?: string | null; }; -export function TaskDetailPanel({ detail, localize, onBack, onOpenReview }: { detail: ApprovalTaskDetail; localize: ReturnType; onBack?: () => void; onOpenReview?: (target: AppReviewTarget) => void }) { +export function TaskDetailPanel({ detail, localize, onBack, onOpenReview, onRefresh }: { detail: ApprovalTaskDetail; localize: ReturnType; onBack?: () => void; onOpenReview?: (target: AppReviewTarget) => void; onRefresh?: () => void }) { const instanceId = detail.instance_id; const serialNo = instanceId ? formatSerialNo(instanceId, detail.create_time) : "--"; @@ -54,7 +54,7 @@ export function TaskDetailPanel({ detail, localize, onBack, onOpenReview }: { de return (
{isAppPublish ? ( - + ) : ( <> ; onBack?: () => void; onOpenReview?: (target: AppReviewTarget) => void }) { +export function RequestDetailPanel({ detail, localize, onBack, onOpenReview, onRefresh }: { detail: ApprovalInstanceDetail; localize: ReturnType; onBack?: () => void; onOpenReview?: (target: AppReviewTarget) => void; onRefresh?: () => void }) { const id = detail.instance_id ?? detail.id; const serialNo = id ? formatSerialNo(Number(id), detail.create_time) : "--"; @@ -235,7 +235,7 @@ export function RequestDetailPanel({ detail, localize, onBack, onOpenReview }: { return (
{isAppPublish ? ( - + ) : ( <> void loadDetail(activeTab === "my_tasks" ? selectedTaskId : selectedInstanceId, activeTab === "my_tasks" ? "task" : "request")}>{localize('com_ui_retry')}
) : activeTab === "my_tasks" && taskDetail ? ( - setCompactView("list")} onOpenReview={setReviewTarget} /> + setCompactView("list")} onOpenReview={setReviewTarget} onRefresh={() => void loadDetail(selectedTaskId, 'task')} /> ) : activeTab === "my_requests" && requestDetail ? ( - setCompactView("list")} onOpenReview={setReviewTarget} /> + setCompactView("list")} onOpenReview={setReviewTarget} onRefresh={() => void loadDetail(selectedInstanceId, 'request')} /> ) : (
{localize("com_approval_empty_detail")}
)} diff --git a/src/frontend/client/src/components/approval/CurrentAppVisibility.test.tsx b/src/frontend/client/src/components/approval/CurrentAppVisibility.test.tsx new file mode 100644 index 0000000000..6dbaf1d476 --- /dev/null +++ b/src/frontend/client/src/components/approval/CurrentAppVisibility.test.tsx @@ -0,0 +1,28 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { CurrentAppVisibility } from './CurrentAppVisibility'; +import type { LocalizeFn } from './approvalPresentation'; +jest.mock('~/utils', () => ({ cn: (...values: unknown[]) => values.filter(Boolean).join(' ') })); +jest.mock('~/hooks', () => ({ useLocalize: () => (key: string) => key })); +const localize: LocalizeFn = key => String(key); + +it('shows current typed subjects and never turns unavailable scope into owner-only', () => { + const refresh = jest.fn(); + const view = render(); + expect(screen.getByText(/Research/)).toBeInTheDocument(); + expect(screen.getByText(/Developers/)).toBeInTheDocument(); + expect(screen.getByText(/approval.current_visibility.children/)).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'approval.current_visibility.refresh' })); + expect(refresh).toHaveBeenCalledTimes(1); + view.rerender(); + expect(screen.getByText('approval.current_visibility.unavailable')).toBeInTheDocument(); + expect(screen.queryByText('approval.current_visibility.ownerOnly')).not.toBeInTheDocument(); +}); + +it.each(['unavailable', 'deleted', undefined] as const)('renders an explicit state for %s', state => { + render(); + expect(screen.queryByText('approval.current_visibility.ownerOnly')).not.toBeInTheDocument(); + expect(screen.getByText(`approval.current_visibility.${state === 'deleted' ? 'deleted' : 'unavailable'}`)).toBeInTheDocument(); +}); diff --git a/src/frontend/client/src/components/approval/CurrentAppVisibility.tsx b/src/frontend/client/src/components/approval/CurrentAppVisibility.tsx new file mode 100644 index 0000000000..554ae6ab8d --- /dev/null +++ b/src/frontend/client/src/components/approval/CurrentAppVisibility.tsx @@ -0,0 +1,31 @@ +import { Button } from '@bisheng/ui'; +import type { AppCurrentVisibility } from '~/api/approval'; +import { formatTime, type LocalizeFn } from './approvalPresentation'; + +interface Props { + value?: AppCurrentVisibility; + localize: LocalizeFn; + onRefresh?: () => void; +} + +export function CurrentAppVisibility({ value, localize, onRefresh }: Props) { + const available = value?.state === 'available'; + return
+
+

{localize('com_approval_app_publish_section_visibility')}

+ {onRefresh && } +
+

{localize('approval.current_visibility.currentHint')}

+ {!available ?

{localize(value?.state === 'deleted' ? 'approval.current_visibility.deleted' : 'approval.current_visibility.unavailable')}

: <> + {value.owner_only === true &&

{localize('approval.current_visibility.ownerOnly')}

} + {value.subjects.length === 0 &&

{localize('approval.current_visibility.noOrdinary')}

} +
    {value.subjects.map((entry, index) =>
  • + {localize(`approval.current_visibility.${entry.type}`)} · {entry.name} ({entry.id}) + {entry.userset_relation && ` · ${localize(`approval.current_visibility.${entry.userset_relation === 'subtree_member' ? 'member' : entry.userset_relation}`)}`} + {entry.include_children && ` · ${localize('approval.current_visibility.children')}`} +
  • )}
+ } + {value?.admin_implicit_access &&

{localize('approval.current_visibility.adminHint')}

} + {value?.checked_at &&

{localize('approval.current_visibility.checkedAt')} {formatTime(value.checked_at)}

} +
; +} diff --git a/src/frontend/client/src/locales/en/api_errors.gen.json b/src/frontend/client/src/locales/en/api_errors.gen.json index 588d2144a1..dd0a2940bd 100644 --- a/src/frontend/client/src/locales/en/api_errors.gen.json +++ b/src/frontend/client/src/locales/en/api_errors.gen.json @@ -152,6 +152,7 @@ "10990": "Processing in the background, try again later", "10991": "The page cursor has expired. Please refresh the list and try again.", "10992": "This list does not show knowledge spaces. Please use the knowledge space page instead.", + "10993": "Your tenant has changed. Reopen the knowledge-base creation form.", "11001": "The Gateway license has expired. This capability is currently unavailable.", "11010": "Invalid SOP file format", "11011": "Failed to set featured SOP case", diff --git a/src/frontend/client/src/locales/en/translation.json b/src/frontend/client/src/locales/en/translation.json index 1fe91af327..2041ef3760 100644 --- a/src/frontend/client/src/locales/en/translation.json +++ b/src/frontend/client/src/locales/en/translation.json @@ -2728,6 +2728,23 @@ "app_release_notes": { "title": "Release notes", "empty": "No release notes provided" + }, + "current_visibility": { + "title": "Visibility", + "refresh": "Refresh scope", + "currentHint": "Current visibility. Authorization changes take effect immediately and do not affect approval. Refresh to see the latest scope.", + "unavailable": "Current visibility could not be confirmed. Please refresh.", + "deleted": "The application was deleted. Current visibility is unavailable.", + "ownerOnly": "Only the owner can see this application", + "noOrdinary": "No ordinary people, departments or user groups currently have visibility.", + "adminHint": "Platform super administrators and this tenant’s administrators have separate implicit management access, which is excluded from this ordinary visibility scope.", + "checkedAt": "Checked at:", + "user": "Person", + "department": "Department", + "user_group": "User group", + "member": "Members", + "admin": "Administrators", + "children": "Includes subdepartments" } }, "chatImage": { diff --git a/src/frontend/client/src/locales/ja/api_errors.gen.json b/src/frontend/client/src/locales/ja/api_errors.gen.json index ed3445bbc8..c995b556fa 100644 --- a/src/frontend/client/src/locales/ja/api_errors.gen.json +++ b/src/frontend/client/src/locales/ja/api_errors.gen.json @@ -152,6 +152,7 @@ "10990": "バックグラウンドで処理中です。後でもう一度お試しください", "10991": "ページネーションが無効になりました。リストを更新して再試行してください。", "10992": "このリストではナレッジスペースを表示できません。ナレッジスペースのページをご利用ください。", + "10993": "所属テナントが変更されました。ナレッジベースの作成フォームを開き直してください。", "11001": "Gateway のライセンスの有効期限が切れています。現在この機能は利用できません。", "11010": "SOPファイル形式が不正です", "11011": "SOPの推薦事例設定に失敗しました", diff --git a/src/frontend/client/src/locales/ja/translation.json b/src/frontend/client/src/locales/ja/translation.json index 4da75fe8fc..cbe956d1b2 100644 --- a/src/frontend/client/src/locales/ja/translation.json +++ b/src/frontend/client/src/locales/ja/translation.json @@ -2651,6 +2651,23 @@ "app_release_notes": { "title": "リリースノート", "empty": "リリースノートは未入力です" + }, + "current_visibility": { + "title": "公開範囲", + "refresh": "範囲を更新", + "currentHint": "現在の公開範囲です。権限変更は即時に反映され、承認には影響しません。更新すると最新の範囲を確認できます。", + "unavailable": "現在の公開範囲を確認できません。更新してください。", + "deleted": "アプリが削除されたため、現在の公開範囲を取得できません。", + "ownerOnly": "責任者のみ閲覧可能", + "noOrdinary": "現在、閲覧可能な一般ユーザー、部門、ユーザーグループはありません。", + "adminHint": "プラットフォームのスーパー管理者とこのテナントの管理者には別途暗黙の管理アクセス権があり、ここに示す一般の公開範囲には含まれません。", + "checkedAt": "確認日時:", + "user": "ユーザー", + "department": "部門", + "user_group": "ユーザーグループ", + "member": "メンバー", + "admin": "管理者", + "children": "下位部門を含む" } }, "chatImage": { diff --git a/src/frontend/client/src/locales/zh-Hans/api_errors.gen.json b/src/frontend/client/src/locales/zh-Hans/api_errors.gen.json index 1d650e1053..4c14aa513e 100644 --- a/src/frontend/client/src/locales/zh-Hans/api_errors.gen.json +++ b/src/frontend/client/src/locales/zh-Hans/api_errors.gen.json @@ -152,6 +152,7 @@ "10990": "后台处理中,请稍后再试", "10991": "分页参数已失效,请刷新列表后重试", "10992": "该列表不支持展示知识空间,请前往知识空间页面查看", + "10993": "所属租户已变化,请重新打开知识库新建表单", "11001": "Gateway 授权已过期,当前无法使用该能力", "11010": "SOP文件格式不符合要求", "11011": "SOP设置精选案例失败", diff --git a/src/frontend/client/src/locales/zh-Hans/translation.json b/src/frontend/client/src/locales/zh-Hans/translation.json index 330895f6b6..70776907d4 100644 --- a/src/frontend/client/src/locales/zh-Hans/translation.json +++ b/src/frontend/client/src/locales/zh-Hans/translation.json @@ -2656,6 +2656,23 @@ "app_release_notes": { "title": "发布说明", "empty": "未填写发布说明" + }, + "current_visibility": { + "title": "可见范围", + "refresh": "刷新范围", + "currentHint": "当前可见范围。授权变更即时生效,不影响审批;刷新后可查看最新结果。", + "unavailable": "暂时无法确认当前可见范围,请刷新重试。", + "deleted": "应用已删除,无法读取当前可见范围。", + "ownerOnly": "仅负责人可见", + "noOrdinary": "当前没有可见的普通人员、部门或用户组。", + "adminHint": "平台超管和本租户管理员另有隐含管理访问资格,不计入此处的普通可见范围。", + "checkedAt": "查询时间:", + "user": "人员", + "department": "部门", + "user_group": "用户组", + "member": "成员", + "admin": "管理员", + "children": "包含下级部门" } }, "chatImage": { diff --git a/src/frontend/packages/locales/src/api_errors/en.json b/src/frontend/packages/locales/src/api_errors/en.json index 3768557a1c..c4d2b51119 100644 --- a/src/frontend/packages/locales/src/api_errors/en.json +++ b/src/frontend/packages/locales/src/api_errors/en.json @@ -1,4 +1,5 @@ { + "10993": "Your tenant has changed. Reopen the knowledge-base creation form.", "403": "No permission to perform this action", "404": "Resource not found", "429": "System is busy, please wait.", diff --git a/src/frontend/packages/locales/src/api_errors/ja.json b/src/frontend/packages/locales/src/api_errors/ja.json index 3cd36b1473..f3770a3282 100644 --- a/src/frontend/packages/locales/src/api_errors/ja.json +++ b/src/frontend/packages/locales/src/api_errors/ja.json @@ -1,4 +1,5 @@ { + "10993": "所属テナントが変更されました。ナレッジベースの作成フォームを開き直してください。", "403": "操作権限がありません", "404": "リソースが見つかりません", "429": "ただいまシステムが混み合っております。少々お待ちください。", diff --git a/src/frontend/packages/locales/src/api_errors/zh-Hans.json b/src/frontend/packages/locales/src/api_errors/zh-Hans.json index 2030364c28..442b4157c7 100644 --- a/src/frontend/packages/locales/src/api_errors/zh-Hans.json +++ b/src/frontend/packages/locales/src/api_errors/zh-Hans.json @@ -1,4 +1,5 @@ { + "10993": "所属租户已变化,请重新打开知识库新建表单", "403": "暂无操作权限", "404": "资源不存在", "429": "系统正忙,请稍候", diff --git a/src/frontend/platform/public/locales/en-US/api_errors.json b/src/frontend/platform/public/locales/en-US/api_errors.json index 588d2144a1..dd0a2940bd 100644 --- a/src/frontend/platform/public/locales/en-US/api_errors.json +++ b/src/frontend/platform/public/locales/en-US/api_errors.json @@ -152,6 +152,7 @@ "10990": "Processing in the background, try again later", "10991": "The page cursor has expired. Please refresh the list and try again.", "10992": "This list does not show knowledge spaces. Please use the knowledge space page instead.", + "10993": "Your tenant has changed. Reopen the knowledge-base creation form.", "11001": "The Gateway license has expired. This capability is currently unavailable.", "11010": "Invalid SOP file format", "11011": "Failed to set featured SOP case", diff --git a/src/frontend/platform/public/locales/en-US/bs.json b/src/frontend/platform/public/locales/en-US/bs.json index 3e57311908..9488bb24eb 100644 --- a/src/frontend/platform/public/locales/en-US/bs.json +++ b/src/frontend/platform/public/locales/en-US/bs.json @@ -4275,5 +4275,41 @@ "notFoundTitle": "Page not found", "notFoundDescription": "The page you are trying to access does not exist or has been removed.", "recommendedAppLimit": "At most {{max}} recommended apps can be configured." + }, + "f115": { + "runtime": { + "unknown": "Unknown", + "notRunning": "Not running", + "noExecution": "No running instance yet", + "executionMissing": "The runtime instance no longer exists" + }, + "audit": { + "candidatesFailed": "Could not load filter options. Please retry.", + "retry": "Retry", + "noVisitors": "No natural-person visits in this range. Calls made by the application itself do not represent a visitor.", + "noCandidates": "No filter options in this range", + "noMatches": "No matching filter options", + "loading": "Loading filter options…" + }, + "knowledge": { + "membershipUnknown": "Your tenant could not be confirmed. Refresh the page and reopen the creation form." + }, + "visibility": { + "title": "Visibility", + "refresh": "Refresh scope", + "currentHint": "Current visibility. Authorization changes take effect immediately and do not affect approval. Refresh to see the latest scope.", + "unavailable": "Current visibility could not be confirmed. Please refresh.", + "deleted": "The application was deleted. Current visibility is unavailable.", + "ownerOnly": "Only the owner can see this application", + "noOrdinary": "No ordinary people, departments or user groups currently have visibility.", + "adminHint": "Platform super administrators and this tenant’s administrators have separate implicit management access, which is excluded from this ordinary visibility scope.", + "checkedAt": "Checked at:", + "user": "Person", + "department": "Department", + "user_group": "User group", + "member": "Members", + "admin": "Administrators", + "children": "Includes subdepartments" + } } } diff --git a/src/frontend/platform/public/locales/ja/api_errors.json b/src/frontend/platform/public/locales/ja/api_errors.json index ed3445bbc8..c995b556fa 100644 --- a/src/frontend/platform/public/locales/ja/api_errors.json +++ b/src/frontend/platform/public/locales/ja/api_errors.json @@ -152,6 +152,7 @@ "10990": "バックグラウンドで処理中です。後でもう一度お試しください", "10991": "ページネーションが無効になりました。リストを更新して再試行してください。", "10992": "このリストではナレッジスペースを表示できません。ナレッジスペースのページをご利用ください。", + "10993": "所属テナントが変更されました。ナレッジベースの作成フォームを開き直してください。", "11001": "Gateway のライセンスの有効期限が切れています。現在この機能は利用できません。", "11010": "SOPファイル形式が不正です", "11011": "SOPの推薦事例設定に失敗しました", diff --git a/src/frontend/platform/public/locales/ja/bs.json b/src/frontend/platform/public/locales/ja/bs.json index 6e5e0a01c7..386815a648 100644 --- a/src/frontend/platform/public/locales/ja/bs.json +++ b/src/frontend/platform/public/locales/ja/bs.json @@ -4220,5 +4220,41 @@ "notFoundTitle": "ページが見つかりません", "notFoundDescription": "アクセスしたページは存在しないか、削除されています。", "recommendedAppLimit": "おすすめのアプリは最大{{max}}件まで設定できます。" + }, + "f115": { + "runtime": { + "unknown": "不明", + "notRunning": "未実行", + "noExecution": "実行インスタンスはまだありません", + "executionMissing": "実行インスタンスは存在しません" + }, + "audit": { + "candidatesFailed": "絞り込み候補を読み込めません。再試行してください。", + "retry": "再試行", + "noVisitors": "この範囲に自然人のアクセス記録はありません。アプリ自身による呼び出しは自然人のアクセスを示しません。", + "noCandidates": "この範囲に絞り込み候補はありません", + "noMatches": "一致する候補はありません", + "loading": "絞り込み候補を読み込み中…" + }, + "knowledge": { + "membershipUnknown": "所属テナントを確認できません。ページを更新して作成フォームを開き直してください。" + }, + "visibility": { + "title": "公開範囲", + "refresh": "範囲を更新", + "currentHint": "現在の公開範囲です。権限変更は即時に反映され、承認には影響しません。更新すると最新の範囲を確認できます。", + "unavailable": "現在の公開範囲を確認できません。更新してください。", + "deleted": "アプリが削除されたため、現在の公開範囲を取得できません。", + "ownerOnly": "責任者のみ閲覧可能", + "noOrdinary": "現在、閲覧可能な一般ユーザー、部門、ユーザーグループはありません。", + "adminHint": "プラットフォームのスーパー管理者とこのテナントの管理者には別途暗黙の管理アクセス権があり、ここに示す一般の公開範囲には含まれません。", + "checkedAt": "確認日時:", + "user": "ユーザー", + "department": "部門", + "user_group": "ユーザーグループ", + "member": "メンバー", + "admin": "管理者", + "children": "下位部門を含む" + } } } diff --git a/src/frontend/platform/public/locales/zh-Hans/api_errors.json b/src/frontend/platform/public/locales/zh-Hans/api_errors.json index 1d650e1053..4c14aa513e 100644 --- a/src/frontend/platform/public/locales/zh-Hans/api_errors.json +++ b/src/frontend/platform/public/locales/zh-Hans/api_errors.json @@ -152,6 +152,7 @@ "10990": "后台处理中,请稍后再试", "10991": "分页参数已失效,请刷新列表后重试", "10992": "该列表不支持展示知识空间,请前往知识空间页面查看", + "10993": "所属租户已变化,请重新打开知识库新建表单", "11001": "Gateway 授权已过期,当前无法使用该能力", "11010": "SOP文件格式不符合要求", "11011": "SOP设置精选案例失败", diff --git a/src/frontend/platform/public/locales/zh-Hans/bs.json b/src/frontend/platform/public/locales/zh-Hans/bs.json index 9e8e33bf7e..70a132444d 100644 --- a/src/frontend/platform/public/locales/zh-Hans/bs.json +++ b/src/frontend/platform/public/locales/zh-Hans/bs.json @@ -4220,5 +4220,41 @@ "notFoundTitle": "页面不存在", "notFoundDescription": "您访问的页面不存在或已被移除。", "recommendedAppLimit": "平台最多支持配置{{max}}个推荐应用" + }, + "f115": { + "runtime": { + "unknown": "未知", + "notRunning": "未运行", + "noExecution": "尚无运行实例", + "executionMissing": "运行实例已不存在" + }, + "audit": { + "candidatesFailed": "筛选选项加载失败,请重试。", + "retry": "重试", + "noVisitors": "当前范围没有自然人访问记录。应用自身调用不代表自然人访问。", + "noCandidates": "当前范围暂无筛选选项", + "noMatches": "没有匹配的筛选选项", + "loading": "正在加载筛选选项…" + }, + "knowledge": { + "membershipUnknown": "暂时无法确认所属租户,请刷新页面后重新打开新建表单。" + }, + "visibility": { + "title": "可见范围", + "refresh": "刷新范围", + "currentHint": "当前可见范围。授权变更即时生效,不影响审批;刷新后可查看最新结果。", + "unavailable": "暂时无法确认当前可见范围,请刷新重试。", + "deleted": "应用已删除,无法读取当前可见范围。", + "ownerOnly": "仅负责人可见", + "noOrdinary": "当前没有可见的普通人员、部门或用户组。", + "adminHint": "平台超管和本租户管理员另有隐含管理访问资格,不计入此处的普通可见范围。", + "checkedAt": "查询时间:", + "user": "人员", + "department": "部门", + "user_group": "用户组", + "member": "成员", + "admin": "管理员", + "children": "包含下级部门" + } } } diff --git a/src/frontend/platform/src/controllers/API/approval.ts b/src/frontend/platform/src/controllers/API/approval.ts index 1127159c3a..ef94aaf79e 100644 --- a/src/frontend/platform/src/controllers/API/approval.ts +++ b/src/frontend/platform/src/controllers/API/approval.ts @@ -62,7 +62,17 @@ export interface ApprovalExceptionItem { applicant_user_name?: string | null; } +export interface AppCurrentVisibility { + state: 'available' | 'unavailable' | 'deleted'; + checked_at?: string; + owner_only?: boolean | null; + admin_implicit_access?: boolean; + subjects: { type: 'user' | 'department' | 'user_group'; id: string; name: string; userset_relation?: 'member' | 'admin' | 'subtree_member'; include_children?: boolean }[]; +} + export interface ApprovalInstanceDetail { + scenario_code?: string; + current_visibility?: AppCurrentVisibility; instance_id?: number; status?: string; flow_nodes?: { diff --git a/src/frontend/platform/src/controllers/API/hostedApp.ts b/src/frontend/platform/src/controllers/API/hostedApp.ts index 9f788db500..caf8631565 100644 --- a/src/frontend/platform/src/controllers/API/hostedApp.ts +++ b/src/frontend/platform/src/controllers/API/hostedApp.ts @@ -59,8 +59,9 @@ export interface HostedAppInstance { health: string | null current_version_id: string | null started_at: string | null - restart_count: number + restart_count: number | null last_probe_at: string | null + observation_state?: 'current' | 'no_execution' | 'execution_missing' } export interface HostedAppVersion { diff --git a/src/frontend/platform/src/pages/ApprovalPage/CurrentAppVisibility.tsx b/src/frontend/platform/src/pages/ApprovalPage/CurrentAppVisibility.tsx new file mode 100644 index 0000000000..abbbea1306 --- /dev/null +++ b/src/frontend/platform/src/pages/ApprovalPage/CurrentAppVisibility.tsx @@ -0,0 +1,32 @@ +import { Button } from '@bisheng/ui'; +import { useTranslation } from 'react-i18next'; +import type { AppCurrentVisibility } from '@/controllers/API/approval'; +import { formatTimestamp } from '@/utils/displayTimestamp'; + +interface Props { + value?: AppCurrentVisibility; + onRefresh: () => void; +} + +export function CurrentAppVisibility({ value, onRefresh }: Props) { + const { t } = useTranslation('bs'); + const available = value?.state === 'available'; + return
+
+

{t('f115.visibility.title')}

+ +
+

{t('f115.visibility.currentHint')}

+ {!available ?

{t(value?.state === 'deleted' ? 'f115.visibility.deleted' : 'f115.visibility.unavailable')}

: <> + {value.owner_only === true &&

{t('f115.visibility.ownerOnly')}

} + {value.subjects.length === 0 &&

{t('f115.visibility.noOrdinary')}

} +
    {value.subjects.map((entry, index) =>
  • + {t(`f115.visibility.${entry.type}`)} · {entry.name} ({entry.id}) + {entry.userset_relation && ` · ${t(`f115.visibility.${entry.userset_relation === 'subtree_member' ? 'member' : entry.userset_relation}`)}`} + {entry.include_children && ` · ${t('f115.visibility.children')}`} +
  • )}
+ } + {value?.admin_implicit_access &&

{t('f115.visibility.adminHint')}

} + {value?.checked_at &&

{t('f115.visibility.checkedAt')} {formatTimestamp(value.checked_at)}

} +
; +} diff --git a/src/frontend/platform/src/pages/ApprovalPage/ExceptionPanel.tsx b/src/frontend/platform/src/pages/ApprovalPage/ExceptionPanel.tsx index d9b301f33b..8a5e1d4b35 100644 --- a/src/frontend/platform/src/pages/ApprovalPage/ExceptionPanel.tsx +++ b/src/frontend/platform/src/pages/ApprovalPage/ExceptionPanel.tsx @@ -5,6 +5,7 @@ import { Outlined } from "bisheng-icons"; import { formatDateTime, HIDDEN_DETAIL_KEYS } from "./approvalMetadata"; import { ActionBtn } from "./ApprovalPrimitives"; import { ApprovalTimeline } from "./ApprovalTimeline"; +import { CurrentAppVisibility } from './CurrentAppVisibility'; import type { useApprovalController } from "./useApprovalController"; interface ExceptionPanelProps { @@ -23,6 +24,7 @@ export function ExceptionPanel({ controller }: ExceptionPanelProps) { setCancelDialogItem, setCancelReason, toggleExceptionProgress, + refreshExceptionProgress, expandedProgressIds, loadingProgressId, instanceDetailCache, @@ -201,9 +203,12 @@ export function ExceptionPanel({ controller }: ExceptionPanelProps) { (loadingProgressId === item.id ? (
{t("loading")}
) : instanceDetailCache[item.id] ? ( - + <> + + {instanceDetailCache[item.id].scenario_code === 'app_publish_request' && void refreshExceptionProgress(item)} />} + ) : ( -
{t("subjectSearch.noProgress")}
+
{t("subjectSearch.noProgress")}
))}
)} diff --git a/src/frontend/platform/src/pages/ApprovalPage/useApprovalController.ts b/src/frontend/platform/src/pages/ApprovalPage/useApprovalController.ts index 28d5392223..455c9621ab 100644 --- a/src/frontend/platform/src/pages/ApprovalPage/useApprovalController.ts +++ b/src/frontend/platform/src/pages/ApprovalPage/useApprovalController.ts @@ -478,6 +478,7 @@ export function useApprovalController() { setCancelReason, handleCancelException, toggleExceptionProgress, + refreshExceptionProgress, expandedProgressIds, loadingProgressId, instanceDetailCache, @@ -485,6 +486,7 @@ export function useApprovalController() { // ── render ──────────────────────────────────────────────────────────────── return { + refreshExceptionProgress, t, setActiveTab, loadPage, diff --git a/src/frontend/platform/src/pages/ApprovalPage/useApprovalExceptions.ts b/src/frontend/platform/src/pages/ApprovalPage/useApprovalExceptions.ts index 5fc5f6e6f8..f9fe13a3be 100644 --- a/src/frontend/platform/src/pages/ApprovalPage/useApprovalExceptions.ts +++ b/src/frontend/platform/src/pages/ApprovalPage/useApprovalExceptions.ts @@ -8,7 +8,7 @@ import { type ApprovalExceptionItem, type ApprovalInstanceDetail, } from "@/controllers/API/approval"; -import { useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; interface ApprovalExceptionsOptions { @@ -37,10 +37,30 @@ export function useApprovalExceptions({ loadPage }: ApprovalExceptionsOptions) { const [expandedProgressIds, setExpandedProgressIds] = useState>(new Set()); const [instanceDetailCache, setInstanceDetailCache] = useState>({}); const [loadingProgressId, setLoadingProgressId] = useState(null); + const revisions = useRef(new Map()); + useEffect(() => () => { revisions.current.clear(); }, []); + + const refreshExceptionProgress = async (item: ApprovalExceptionItem) => { + if (!item.instance_id) return; + const id = item.id; + const revision = (revisions.current.get(id) ?? 0) + 1; + revisions.current.set(id, revision); + setInstanceDetailCache(prev => { const next = { ...prev }; delete next[id]; return next; }); + setLoadingProgressId(id); + try { + const detail = await getApprovalInstanceDetailForAdminApi(item.instance_id); + if (revisions.current.get(id) === revision) setInstanceDetailCache(prev => ({ ...prev, [id]: detail })); + } catch (error) { + if (revisions.current.get(id) === revision) showApiError(error); + } finally { + if (revisions.current.get(id) === revision) setLoadingProgressId(prev => prev === id ? null : prev); + } + }; const toggleExceptionProgress = async (item: ApprovalExceptionItem) => { const id = item.id; if (expandedProgressIds.has(id)) { + revisions.current.set(id, (revisions.current.get(id) ?? 0) + 1); setExpandedProgressIds((prev) => { const s = new Set(prev); s.delete(id); @@ -53,21 +73,7 @@ export function useApprovalExceptions({ loadPage }: ApprovalExceptionsOptions) { s.add(id); return s; }); - if (!item.instance_id || instanceDetailCache[id]) return; - setLoadingProgressId(id); - try { - const detail = await getApprovalInstanceDetailForAdminApi(item.instance_id); - setInstanceDetailCache((prev) => ({ ...prev, [id]: detail })); - } catch (error) { - showApiError(error); - setExpandedProgressIds((prev) => { - const next = new Set(prev); - next.delete(id); - return next; - }); - } finally { - setLoadingProgressId(null); - } + await refreshExceptionProgress(item); }; const handleCancelException = async () => { @@ -95,6 +101,7 @@ export function useApprovalExceptions({ loadPage }: ApprovalExceptionsOptions) { setCancelReason, handleCancelException, toggleExceptionProgress, + refreshExceptionProgress, expandedProgressIds, loadingProgressId, instanceDetailCache, diff --git a/src/frontend/platform/src/pages/BuildPage/hostedApp/tabs/PublishTab.tsx b/src/frontend/platform/src/pages/BuildPage/hostedApp/tabs/PublishTab.tsx index 628eedbf43..3725150b6c 100644 --- a/src/frontend/platform/src/pages/BuildPage/hostedApp/tabs/PublishTab.tsx +++ b/src/frontend/platform/src/pages/BuildPage/hostedApp/tabs/PublishTab.tsx @@ -75,7 +75,7 @@ interface PublishTabProps { pendingReason?: HostedAppPendingReason | null } -/** Container health → label; an unknown value is shown as sent. */ +/** Only normalized, current runtime facts are displayable. */ const HEALTH_I18N: Record = { healthy: "hostedApp.publish.healthValue.healthy", unhealthy: "hostedApp.publish.healthValue.unhealthy", @@ -150,11 +150,23 @@ export function PublishTab({ ) - const health = instance?.health - ? HEALTH_I18N[instance.health] - ? t(HEALTH_I18N[instance.health]) - : instance.health - : "-" + const current = instance?.observation_state === 'current' + const unknown = t('f115.runtime.unknown', { ns: 'bs' }) + const health = current && instance?.phase === 'stopped' + ? t('f115.runtime.notRunning', { ns: 'bs' }) + : current && instance?.health && HEALTH_I18N[instance.health] + ? t(HEALTH_I18N[instance.health]) : unknown + const restartCount = current && typeof instance?.restart_count === 'number' + && Number.isInteger(instance.restart_count) && instance.restart_count >= 0 ? instance.restart_count : unknown + const startedAt = current && instance?.started_at + && /(Z|[+-]\d{2}:\d{2})$/i.test(instance.started_at) + && Number.isFinite(Date.parse(instance.started_at)) + && new Date(instance.started_at).getUTCFullYear() > 1 ? formatTimestamp(instance.started_at) : unknown + const phase = instance?.observation_state === 'no_execution' + ? t('f115.runtime.noExecution', { ns: 'bs' }) + : instance?.observation_state === 'execution_missing' + ? t('f115.runtime.executionMissing', { ns: 'bs' }) + : current ? t(phaseI18nKey(instance.phase)) : unknown return (
@@ -235,19 +247,19 @@ export function PublishTab({ {instance ? ( <> - {t(phaseI18nKey(instance.phase))} + {phase} {health} - {instance.restart_count ?? 0} + {restartCount} - {formatTimestamp(instance.started_at)} + {startedAt} diff --git a/src/frontend/platform/src/pages/KnowledgePage/KnowledgeFile.tsx b/src/frontend/platform/src/pages/KnowledgePage/KnowledgeFile.tsx index 5c3753ebb2..cf066f4b8f 100644 --- a/src/frontend/platform/src/pages/KnowledgePage/KnowledgeFile.tsx +++ b/src/frontend/platform/src/pages/KnowledgePage/KnowledgeFile.tsx @@ -110,14 +110,14 @@ export default function KnowledgeFile({ toolbarTarget }: KnowledgeFileProps) { hasRowAction(el, 'edit'); const canDelete = (el: KnowledgeListRow) => hasRowAction(el, 'delete'); - // PRD 3.3.3:「创建」「复制」与 ReBAC 编辑权解耦,由 WEB_MENU `create_knowledge` 控制(对齐「创建应用」+ 列表「复制」) - const canCreateLibrary = + // Copy retains the original menu qualification and source visibility. + const canCopyLibrary = user.role === 'admin' || (user.web_menu || []).includes('create_knowledge'); + const canCreateLibrary = canCopyLibrary || user.is_child_admin === true; const canReadRow = (el: KnowledgeListRow) => hasAction(el, 'visible'); - /** 与 apps.tsx 一致:create_knowledge 菜单 + 对目标库具备使用/可见(can_read) */ - const canUseCopy = (el: KnowledgeListRow) => canCreateLibrary && canReadRow(el); + const canUseCopy = (el: KnowledgeListRow) => canCopyLibrary && canReadRow(el); const canManageKb = (el: KnowledgeListRow) => hasRowAction(el, KB_MANAGE_ACTION); const isLibraryBusy = (el: KnowledgeListRow) => diff --git a/src/frontend/platform/src/pages/KnowledgePage/KnowledgeQa.tsx b/src/frontend/platform/src/pages/KnowledgePage/KnowledgeQa.tsx index 45790b968a..44b6f100ba 100644 --- a/src/frontend/platform/src/pages/KnowledgePage/KnowledgeQa.tsx +++ b/src/frontend/platform/src/pages/KnowledgePage/KnowledgeQa.tsx @@ -85,12 +85,13 @@ export default function KnowledgeQa({ toolbarTarget }: KnowledgeQaProps) { hasRowAction(el, 'edit'); const canDelete = (el: KnowledgeListRow) => hasRowAction(el, 'delete'); - const canCreateLibrary = + const canCopyLibrary = user.role === 'admin' || (user.web_menu || []).includes('create_knowledge'); + const canCreateLibrary = canCopyLibrary || user.is_child_admin === true; const canReadRow = (el: KnowledgeListRow) => hasAction(el, 'visible'); - const canUseCopy = (el: KnowledgeListRow) => canCreateLibrary && canReadRow(el); + const canUseCopy = (el: KnowledgeListRow) => canCopyLibrary && canReadRow(el); const canManageKb = (el: KnowledgeListRow) => hasRowAction(el, KB_MANAGE_ACTION); const isLibraryBusy = (el: KnowledgeListRow) => diff --git a/src/frontend/platform/src/pages/KnowledgePage/components/KnowledgeLibraryDialog.tsx b/src/frontend/platform/src/pages/KnowledgePage/components/KnowledgeLibraryDialog.tsx index 42d7676bb0..7640540542 100644 --- a/src/frontend/platform/src/pages/KnowledgePage/components/KnowledgeLibraryDialog.tsx +++ b/src/frontend/platform/src/pages/KnowledgePage/components/KnowledgeLibraryDialog.tsx @@ -3,7 +3,8 @@ import { Dialog,DialogClose,DialogContent,DialogFooter,DialogHeader,DialogTitle import { getKnowledgeModelConfig } from "@/controllers/API/finetune"; import { Outlined } from "bisheng-icons"; -import { useEffect,useRef,useState } from "react"; +import { useContext,useEffect,useRef,useState } from "react"; +import { userContext } from '@/contexts/userContext'; import { useTranslation } from "react-i18next"; import { useNavigate } from "react-router-dom"; @@ -23,6 +24,16 @@ interface LibraryDialogProps { export function KnowledgeLibraryDialog({ datalist, open, onOpenChange, onLoadEnd, mode = 'create', currentLib = null }: LibraryDialogProps) { const { t } = useTranslation('knowledge') + const { user } = useContext(userContext) + const expectedTenant = useRef(null) + const wasOpen = useRef(false) + useEffect(() => { + if (open && !wasOpen.current) { + const tenant = user?.leaf_tenant_id + expectedTenant.current = Number.isInteger(tenant) && tenant > 0 ? tenant : null + } + wasOpen.current = open + }, [open, user?.leaf_tenant_id]) const navigate = useNavigate() @@ -76,6 +87,10 @@ export function KnowledgeLibraryDialog({ datalist, open, onOpenChange, onLoadEnd const [error, setError] = useState({ name: false, desc: false }) const handleCreate = async (isImport = false) => { + if (mode === 'create' && expectedTenant.current === null) { + handleError(t('f115.knowledge.membershipUnknown', { ns: 'bs' })) + return + } const name = nameRef.current!.value || ''; // Name (default empty string to avoid null) let desc = descRef.current!.value || ''; // Description (default empty string) @@ -136,6 +151,7 @@ export function KnowledgeLibraryDialog({ datalist, open, onOpenChange, onLoadEnd name, description: desc, model: modelId, + expected_tenant_id: expectedTenant.current, type: 0 }).then(response => { const res = response as unknown as { id: string | number }; diff --git a/src/frontend/platform/src/pages/KnowledgePage/components/QaLibraryDialog.tsx b/src/frontend/platform/src/pages/KnowledgePage/components/QaLibraryDialog.tsx index a839b05ad7..e5bc46d702 100644 --- a/src/frontend/platform/src/pages/KnowledgePage/components/QaLibraryDialog.tsx +++ b/src/frontend/platform/src/pages/KnowledgePage/components/QaLibraryDialog.tsx @@ -8,7 +8,8 @@ import { createFileLib, updateKnowledge } from "@/controllers/API"; import { getKnowledgeModelConfig } from "@/controllers/API/finetune"; import { showApiError } from "@/controllers/request"; import { ModelSelect } from "@/pages/ModelPage/manage/tabs/WorkbenchModel"; -import { useEffect, useRef, useState } from "react"; +import { useContext, useEffect, useRef, useState } from "react"; +import { userContext } from '@/contexts/userContext'; import { useTranslation } from "react-i18next"; import { useNavigate } from "react-router-dom"; import { useModel } from "../../ModelPage/manage"; @@ -23,6 +24,16 @@ interface LibraryDialogProps { export function QaLibraryDialog({ datalist, open, onOpenChange, onLoadEnd, mode = 'create', currentLib = null }: LibraryDialogProps) { const { t } = useTranslation('knowledge') + const { user } = useContext(userContext) + const expectedTenant = useRef(null) + const wasOpen = useRef(false) + useEffect(() => { + if (open && !wasOpen.current) { + const tenant = user?.leaf_tenant_id + expectedTenant.current = Number.isInteger(tenant) && tenant > 0 ? tenant : null + } + wasOpen.current = open + }, [open, user?.leaf_tenant_id]) const navigate = useNavigate() const nameRef = useRef(null) @@ -72,6 +83,10 @@ export function QaLibraryDialog({ datalist, open, onOpenChange, onLoadEnd, mode const handleCreate = async (isImport = false) => { + if (mode === 'create' && expectedTenant.current === null) { + notification.open({ type: 'error', description: t('f115.knowledge.membershipUnknown', { ns: 'bs' }) }) + return + } const name = nameRef.current!.value || ''; let desc = descRef.current!.value || ''; @@ -112,6 +127,7 @@ export function QaLibraryDialog({ datalist, open, onOpenChange, onLoadEnd, mode name, description: desc, model: modelId, + expected_tenant_id: expectedTenant.current, type: 1 }).then((response) => { const res = response as unknown as { id: string | number }; diff --git a/src/frontend/platform/src/pages/LogPage/runtime/RuntimeAuditPanel.tsx b/src/frontend/platform/src/pages/LogPage/runtime/RuntimeAuditPanel.tsx index fdac124a6d..344b10ab6c 100644 --- a/src/frontend/platform/src/pages/LogPage/runtime/RuntimeAuditPanel.tsx +++ b/src/frontend/platform/src/pages/LogPage/runtime/RuntimeAuditPanel.tsx @@ -24,15 +24,18 @@ interface CandidateProps { value?: string onChange: (value: string | undefined) => void } -function CandidateFilter({ kind, dimension, filters, scopeKey, value, onChange }: CandidateProps) { +export function CandidateFilter({ kind, dimension, filters, scopeKey, value, onChange }: CandidateProps) { const { t } = useTranslation() const labels = useSelectLabels() const [search, setSearch] = useState('') const query = useInfiniteQuery({ queryKey: ['runtime-audit-candidates', scopeKey, kind, dimension, filters, search], queryFn: ({ pageParam = 0 }) => getRuntimeAuditCandidates(kind, dimension, filters, search, Number(pageParam)), getNextPageParam: (last, pages) => last.has_more ? pages.length * 100 : undefined, retry: false }) + const empty = query.isError ? t('f115.audit.candidatesFailed') + : dimension === 'visitor' ? t('f115.audit.noVisitors') : t('f115.audit.noCandidates') return
({ value, label: t(`log.runtime.${value}`) }))} onChange={value => setRange(value as RuntimeAuditFilters['time_range'])} />
- setFilters({ ...filters, app_id: value })} /> - setFilters({ ...filters, visitor_id: value ? Number(value) : undefined })} /> - {kind === 'model' && setFilters({ ...filters, credential_id: value ? Number(value) : undefined })} />} + setFilters({ ...filters, app_id: value, visitor_id: undefined })} /> + setFilters({ ...filters, visitor_id: value ? Number(value) : undefined })} /> + {kind === 'model' && setFilters({ ...filters, credential_id: value ? Number(value) : undefined, visitor_id: undefined })} />} {range === 'custom' && <>
diff --git a/src/frontend/platform/src/test/f115ApprovalVisibility.test.tsx b/src/frontend/platform/src/test/f115ApprovalVisibility.test.tsx new file mode 100644 index 0000000000..2754ff45d8 --- /dev/null +++ b/src/frontend/platform/src/test/f115ApprovalVisibility.test.tsx @@ -0,0 +1,29 @@ +import { act, cleanup, render, renderHook, screen } from '@testing-library/react' +import { afterEach, expect, it, vi } from 'vitest' +import { CurrentAppVisibility } from '@/pages/ApprovalPage/CurrentAppVisibility' +import { useApprovalExceptions } from '@/pages/ApprovalPage/useApprovalExceptions' + +const mock = vi.hoisted(() => ({ detail: vi.fn() })) +vi.mock('@/controllers/API/approval', () => ({ getApprovalInstanceDetailForAdminApi: mock.detail })) +vi.mock('@/controllers/request', () => ({ showApiError: vi.fn() })) +afterEach(() => { cleanup(); vi.clearAllMocks() }) + +it('keeps unavailable scope distinct from owner-only and exposes a refresh action', () => { + const refresh = vi.fn() + const view = render() + expect(screen.getByText('f115.visibility.unavailable')).toBeVisible() + expect(screen.queryByText('f115.visibility.ownerOnly')).toBeNull() + view.rerender() + expect(screen.getByText('f115.visibility.ownerOnly')).toBeVisible() + expect(screen.getByText(/Owner/)).toBeVisible() +}) + +it('reloads current authorization every time exception progress is reopened', async () => { + mock.detail.mockResolvedValue({ instance_id: 1, current_visibility: { state: 'available', subjects: [] } }) + const { result } = renderHook(() => useApprovalExceptions({ loadPage: vi.fn() })) + const item = { id: 3, instance_id: 1, exception_type: 'approver_empty' } + await act(() => result.current.toggleExceptionProgress(item)) + await act(() => result.current.toggleExceptionProgress(item)) + await act(() => result.current.toggleExceptionProgress(item)) + expect(mock.detail).toHaveBeenCalledTimes(2) +}) diff --git a/src/frontend/platform/src/test/f115KnowledgeCreation.test.tsx b/src/frontend/platform/src/test/f115KnowledgeCreation.test.tsx new file mode 100644 index 0000000000..d188090477 --- /dev/null +++ b/src/frontend/platform/src/test/f115KnowledgeCreation.test.tsx @@ -0,0 +1,47 @@ +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { beforeEach, afterEach, expect, it, vi } from 'vitest' +import { userContext } from '@/contexts/userContext' +import { KnowledgeLibraryDialog } from '@/pages/KnowledgePage/components/KnowledgeLibraryDialog' +import { QaLibraryDialog } from '@/pages/KnowledgePage/components/QaLibraryDialog' +import KnowledgeFile from '@/pages/KnowledgePage/KnowledgeFile' +import KnowledgeQa from '@/pages/KnowledgePage/KnowledgeQa' + +const mock = vi.hoisted(() => ({ create: vi.fn(), config: vi.fn(), navigate: vi.fn() })) +vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key, i18n: { loadNamespaces: vi.fn() } }) })) +vi.mock('@/contexts/userContext', async () => ({ userContext: (await import('react')).createContext({ user: {} }) })) +vi.mock('@/controllers/API', () => ({ createFileLib: mock.create, updateKnowledge: vi.fn(), readFileLibDatabase: vi.fn(), copyLibDatabase: vi.fn(), copyQaDatabase: vi.fn(), deleteFileLib: vi.fn() })) +vi.mock('@/util/hook', () => ({ useInfiniteCursorTable: () => ({ data: [], loading: false, hasMore: false, search: vi.fn(), reload: vi.fn(), loadMore: vi.fn() }) })) +vi.mock('@/components/bs-comp/permission/useResourceActions', () => ({ useLazyResourceActions: () => ({ actions: {}, loading: false, load: vi.fn() }) })) +vi.mock('@/components/bs-comp/permission/PermissionDialog', () => ({ PermissionDialog: () => null })) +vi.mock('@/controllers/API/finetune', () => ({ getKnowledgeModelConfig: mock.config })) +vi.mock('@/controllers/request', () => ({ showApiError: vi.fn() })) +vi.mock('react-router-dom', () => ({ useNavigate: () => mock.navigate })) +vi.mock('@/pages/ModelPage/manage', () => ({ useModel: () => ({ embeddings: [], isLoading: false }) })) +vi.mock('@/pages/ModelPage/manage/tabs/WorkbenchModel', () => ({ ModelSelect: () => null })) +afterEach(cleanup) +beforeEach(() => { vi.clearAllMocks(); mock.config.mockResolvedValue({ embedding_model_id: 1 }); mock.create.mockResolvedValue({ id: 1 }) }) + +it.each([KnowledgeFile, KnowledgeQa])('exposes creation to a tenant admin without a create menu', Page => { + const view = render() + expect(screen.getByRole('button', { name: 'create' })).toBeVisible() + view.rerender() + expect(screen.queryByRole('button', { name: 'create' })).toBeNull() +}) + +it.each([KnowledgeLibraryDialog, QaLibraryDialog])('fixes expected tenant when opening a new-library form', async Dialog => { + const props = { datalist: [], open: true, onOpenChange: vi.fn(), onLoadEnd: vi.fn() } + const view = render() + await waitFor(() => expect(mock.config).toHaveBeenCalled()) + view.rerender() + fireEvent.change(screen.getByPlaceholderText('lib.enterLibraryName'), { target: { value: 'Test library' } }) + fireEvent.click(screen.getByRole('button', { name: /^(finishCreate|completeCreation)$/ })) + await waitFor(() => expect(mock.create).toHaveBeenCalledWith(expect.objectContaining({ expected_tenant_id: 60 }))) +}) + +it.each([KnowledgeLibraryDialog, QaLibraryDialog])('blocks new-library submission when current membership is unknown', async Dialog => { + render() + await waitFor(() => expect(mock.config).toHaveBeenCalled()) + fireEvent.change(screen.getByPlaceholderText('lib.enterLibraryName'), { target: { value: 'Test library' } }) + fireEvent.click(screen.getByRole('button', { name: /^(finishCreate|completeCreation)$/ })) + expect(mock.create).not.toHaveBeenCalled() +}) diff --git a/src/frontend/platform/src/test/f115RuntimeReview.test.tsx b/src/frontend/platform/src/test/f115RuntimeReview.test.tsx new file mode 100644 index 0000000000..ae84085de9 --- /dev/null +++ b/src/frontend/platform/src/test/f115RuntimeReview.test.tsx @@ -0,0 +1,54 @@ +import { cleanup, render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import type { PropsWithChildren } from 'react' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import type { HostedAppDetail, HostedAppInstance } from '@/controllers/API/hostedApp' +import { PublishTab } from '@/pages/BuildPage/hostedApp/tabs/PublishTab' +import { CandidateFilter } from '@/pages/LogPage/runtime/RuntimeAuditPanel' + +const mock = vi.hoisted(() => ({ candidates: vi.fn() })) +vi.mock('@/controllers/API/runtimeAudit', () => ({ getRuntimeAuditCandidates: mock.candidates })) +vi.mock('@/pages/BuildPage/useHostedAppActions', () => ({ useHostedAppActions: () => ({}) })) +vi.mock('@/utils', () => ({ copyText: vi.fn() })) +vi.mock('@/contexts/userContext', async () => ({ userContext: (await import('react')).createContext({ user: {} }) })) +vi.mock('@/contexts/locationContext', async () => ({ locationContext: (await import('react')).createContext({ appConfig: {} }) })) +vi.mock('@/controllers/request', () => ({ showApiError: vi.fn() })) + +const app: HostedAppDetail = { app_id: 'a', slug: 'app', name: 'App', description: null, logo: null, owner_user_id: 9, tenant_id: 60, current_version_id: 'v1', pending_version_id: null, create_time: null, update_time: null, state: 'online', allowed_actions: [], entry_url: '/apps/app' } +const instance = { phase: 'running', health: 'healthy', restart_count: 0, started_at: '2026-11-02T01:02:03Z', observation_state: 'current' } as HostedAppInstance +const wrapper = ({ children }: PropsWithChildren) => {children} +afterEach(cleanup) +beforeEach(() => { vi.clearAllMocks(); mock.candidates.mockResolvedValue({ items: [], has_more: false }) }) + +it('preserves real zero but refuses stale or invalid runtime facts', () => { + const view = render() + expect(screen.getByText('0')).toBeVisible() + view.rerender() + expect(screen.queryByText('0')).toBeNull() + expect(screen.getByText('f115.runtime.executionMissing')).toBeVisible() + view.rerender() + expect(screen.queryByText('private-native-value')).toBeNull() + expect(screen.queryByText('invalid')).toBeNull() + expect(screen.getAllByText('f115.runtime.unknown').length).toBeGreaterThan(0) +}) + +it('distinguishes empty candidates from failure and retries without fabricated users', async () => { + const user = userEvent.setup() + mock.candidates.mockRejectedValueOnce(new Error('offline')) + render(, { wrapper }) + expect(await screen.findByRole('alert')).toHaveTextContent('f115.audit.candidatesFailed') + await user.click(screen.getByRole('button', { name: 'f115.audit.retry' })) + await waitFor(() => expect(screen.queryByRole('alert')).toBeNull()) + await user.click(screen.getByRole('combobox')) + expect(await screen.findByText('f115.audit.noVisitors')).toBeVisible() +}) + +it('does not show old-tenant candidates while the new range is loading', async () => { + mock.candidates.mockResolvedValue({ items: [{ id: '9', label: 'Alice' }], has_more: false }) + const view = render(, { wrapper }) + await waitFor(() => expect(mock.candidates).toHaveBeenCalled()) + mock.candidates.mockReturnValue(new Promise(() => undefined)) + view.rerender() + expect(screen.queryByText('Alice')).toBeNull() +}) diff --git a/src/runtime-manager/runtime_manager/api/platform_observe.py b/src/runtime-manager/runtime_manager/api/platform_observe.py index 70a16191b6..afaad58656 100644 --- a/src/runtime-manager/runtime_manager/api/platform_observe.py +++ b/src/runtime-manager/runtime_manager/api/platform_observe.py @@ -7,7 +7,8 @@ from runtime_manager.api.operations import authorized, executor from runtime_manager.api.readonly import _parse_since from runtime_manager.container_runner import OP_LABEL, redact -from runtime_manager.errors import NotFoundError +from runtime_manager.errors import BackendUnavailableError, NotFoundError +from runtime_manager.observation import public_status from runtime_manager.platform_client import PlatformError router = APIRouter(prefix="/v2", dependencies=[Depends(authorized)]) @@ -45,26 +46,66 @@ def record(app_id): @router.get("/apps/{app_id}/status") async def status(app_id: str): - _service, scope, data = await asyncio.to_thread(record, app_id) - if "observation" in data: - observation = data["observation"] - return { - "app_id": app_id, - "phase": "running" if observation.get("ready") else "unhealthy", - "generation": scope["generation"], - "instance_id": scope["exec_ref"], - "health": "healthy" if observation.get("ready") else "unhealthy", - "restart_count": observation.get("restart_count", 0), - } - state = data.get("State", {}) - return { - "app_id": app_id, - "phase": "running" if state.get("Running") else "unhealthy", - "generation": scope["generation"], - "instance_id": scope["exec_ref"], - "health": state.get("Health", {}).get("Status"), - "restart_count": data.get("RestartCount", 0), - } + return await asyncio.to_thread(_current_status, app_id) + + +def _current_status(app_id): + service = executor() + try: + scope = service.platform.application(app_id) + except PlatformError as exc: + if exc.status == 404 and str(exc) == "application_not_found": + return public_status(app_id, {}, {}, state="no_execution") + raise + ref = scope.get("exec_ref") + if not ref: + return public_status(app_id, scope, {}, state="no_execution") + try: + if service.config.executor_kind == "kubernetes": + row = service.platform.resource(ref) + if (row.get("app_id"), row.get("node_id"), row.get("operation_id"), row.get("kind")) != ( + app_id, + service.config.node_id, + scope.get("operation_id"), + "Pod", + ) or not row.get("uid"): + raise BackendUnavailableError("Execution assignment mismatch") + else: + raw = service.docker.inspect_container(ref) + labels = raw.get("Config", {}).get("Labels", {}) + if ( + labels.get("bisheng.app.id") != app_id + or labels.get("bisheng.runtime.node") != service.config.node_id + or labels.get(OP_LABEL) != scope.get("operation_id") + or labels.get("bisheng.managed") != "v2" + ): + raise BackendUnavailableError("Execution assignment mismatch") + observation = ( + service.runtime.observe_status(ref) + if service.config.executor_kind == "kubernetes" + else service.runtime.observe(ref) + ) + except PlatformError as exc: + raise BackendUnavailableError("Platform execution assignment unavailable") from exc + except Exception as exc: + if ( + service.config.executor_kind != "kubernetes" + and getattr(exc, "status_code", getattr(exc, "status", None)) == 404 + ): + observation = None + else: + raise + current = service.platform.application(app_id) + keys = ("generation", "operation_id", "exec_ref") + if any(current.get(key) != scope.get(key) for key in keys): + raise BackendUnavailableError("Execution assignment changed during observation") + if service.config.executor_kind == "kubernetes": + latest = service.platform.resource(ref) + if latest.get("uid") != row.get("uid"): + raise BackendUnavailableError("Execution assignment UID changed during observation") + return public_status( + app_id, scope, observation or {}, state="current" if observation is not None else "execution_missing" + ) @router.get("/apps/{app_id}/logs") diff --git a/src/runtime-manager/runtime_manager/executors/docker.py b/src/runtime-manager/runtime_manager/executors/docker.py index 661e6403d4..593609a873 100644 --- a/src/runtime-manager/runtime_manager/executors/docker.py +++ b/src/runtime-manager/runtime_manager/executors/docker.py @@ -257,8 +257,12 @@ def cleanup(self, operation): return {"complete": True} def observe(self, exec_ref): + from datetime import UTC, datetime + import httpx + from runtime_manager.observation import counter, timestamp + record = self._record(exec_ref) labels = record.get("Config", {}).get("Labels", {}) operation = self.platform.operation(labels[OP_LABEL]) @@ -267,6 +271,8 @@ def observe(self, exec_ref): host = network.get("IPAddress") or network.get("GlobalIPv6Address") or "" port = int(operation["payload"].get("port", 8080)) ready = False + probe_state = "pending" if record.get("State", {}).get("Restarting") else "unknown" + last_probe_at = None if running and host: path = operation["payload"].get("health", {}).get("path", "/") if not path.startswith("/") or any(c in path for c in "\r\n"): @@ -280,8 +286,22 @@ def observe(self, exec_ref): < 500 ) except httpx.HTTPError: - pass - return {"exec_ref": exec_ref, "running": running, "ready": ready, "target": {"host": host, "port": port}} + # An actual application probe failed; this is not a platform lookup failure. + ready = False + probe_state = "passed" if ready else "failed" + last_probe_at = datetime.now(UTC).isoformat() + elif operation.get("stage") == "starting" or record.get("State", {}).get("Restarting"): + probe_state = "pending" + return { + "exec_ref": exec_ref, + "running": running, + "ready": ready, + "target": {"host": host, "port": port}, + "probe_state": probe_state, + "restart_count": counter(record.get("RestartCount")), + "started_at": timestamp(record.get("State", {}).get("StartedAt")), + "last_probe_at": last_probe_at, + } def logs(self, exec_ref, *, since=None, tail=200): self._record(exec_ref) diff --git a/src/runtime-manager/runtime_manager/executors/kubernetes.py b/src/runtime-manager/runtime_manager/executors/kubernetes.py index 11274daa08..7ef76dee1d 100644 --- a/src/runtime-manager/runtime_manager/executors/kubernetes.py +++ b/src/runtime-manager/runtime_manager/executors/kubernetes.py @@ -756,7 +756,20 @@ def observe(self, exec_ref): row = self.platform.resource(exec_ref) return self._observed(row, self._fresh(row)) + def observe_status(self, exec_ref): + """Only an exact Pod 404 proves absence; helper/API failures do not.""" + row = self.platform.resource(exec_ref) + try: + pod = self._fresh(row) + except KubernetesAPIError as exc: + if exc.status_code == 404: + return None + raise + return self._observed(row, pod) + def _observed(self, row, pod): + from runtime_manager.observation import counter, timestamp + exec_ref = row["resource_ref"] status = pod.get("status", {}) ready = ( @@ -791,6 +804,12 @@ def _observed(self, row, pod): for e in item.get("endpoints", []) ) target = service.get("observed_state", {}) if service else {} + app_status = next((c for c in status.get("containerStatuses", []) if c.get("name") == "app"), {}) + container_state = app_status.get("state", {}) + current_state = container_state.get("running") or container_state.get("terminated") or {} + pending = status.get("phase") == "Pending" or bool(container_state.get("waiting")) + known_ready = any(c.get("type") == "Ready" for c in status.get("conditions", [])) + probe_state = "passed" if ready else ("pending" if pending else ("failed" if known_ready else "unknown")) return { "exec_ref": exec_ref, "pod_uid": row["uid"], @@ -798,9 +817,9 @@ def _observed(self, row, pod): "target": {"host": target.get("cluster_ip", ""), "port": target.get("port", 0)}, "phase": status.get("phase", "Unknown"), "running": status.get("phase") == "Running" and not pod["metadata"].get("deletionTimestamp"), - "restart_count": sum( - int(c.get("restartCount", 0)) for c in status.get("containerStatuses", []) if c.get("name") == "app" - ), + "restart_count": counter(app_status.get("restartCount")), + "started_at": timestamp(current_state.get("startedAt")), + "probe_state": probe_state, } @staticmethod diff --git a/src/runtime-manager/runtime_manager/observation.py b/src/runtime-manager/runtime_manager/observation.py new file mode 100644 index 0000000000..34da5e840a --- /dev/null +++ b/src/runtime-manager/runtime_manager/observation.py @@ -0,0 +1,47 @@ +"""Public current-execution facts; missing observations are never zero values.""" + +from datetime import UTC, datetime + + +def counter(value): + return value if type(value) is int and value >= 0 else None + + +def timestamp(value): + if not isinstance(value, str): + return None + try: + parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) + if parsed.year <= 1 or parsed.tzinfo is None: + return None + return parsed.astimezone(UTC).isoformat().replace("+00:00", "Z") + except (ValueError, OverflowError): + return None + + +def public_status(app_id, scope, observed, *, state="current"): + running = observed.get("running") + probe = observed.get("probe_state") + if probe is None and observed.get("ready") is True: + probe = "passed" + health = {"passed": "healthy", "failed": "unhealthy", "pending": "starting"}.get(probe) + phase = "running" if running else "stopped" + if probe == "pending": + phase = "starting" + elif health == "unhealthy": + phase = "unhealthy" + if running is False and probe != "pending": + phase, health = "stopped", None + if state == "execution_missing": + phase, health = "unhealthy", None + return { + "app_id": app_id, + "generation": scope.get("generation"), + "instance_id": scope.get("exec_ref"), + "phase": phase, + "health": health, + "restart_count": counter(observed.get("restart_count")), + "started_at": timestamp(observed.get("started_at")), + "last_probe_at": timestamp(observed.get("last_probe_at")), + "observation_state": state, + } diff --git a/src/runtime-manager/tests/test_f115_observation.py b/src/runtime-manager/tests/test_f115_observation.py new file mode 100644 index 0000000000..05367b0c60 --- /dev/null +++ b/src/runtime-manager/tests/test_f115_observation.py @@ -0,0 +1,177 @@ +"""F115 current execution facts: AC-01 through AC-10, never historical guesses.""" + +from copy import deepcopy +from types import SimpleNamespace + +import httpx +import pytest + +from runtime_manager.api import platform_observe +from runtime_manager.config import Config +from runtime_manager.container_runner import OP_LABEL +from runtime_manager.executors.docker import DockerExecutor +from runtime_manager.executors.kubernetes import KubernetesExecutor + + +@pytest.fixture +def docker_service(tmp_path, monkeypatch): + scope = {"app_id": "app-a", "node_id": "node-a", "operation_id": "op-a", "exec_ref": "container-a", "generation": 2} + raw = { + "Config": { + "Labels": { + "bisheng.runtime.node": "node-a", + "bisheng.app.id": "app-a", + "bisheng.managed": "v2", + OP_LABEL: "op-a", + } + }, + "State": {"Running": True, "StartedAt": "2026-10-30T10:33:15.363879485Z"}, + "RestartCount": 0, + "NetworkSettings": {"Networks": {"bisheng-apps": {"IPAddress": "127.0.0.2"}}}, + } + + class Platform: + def application(self, app_id): + assert app_id == "app-a" + return dict(scope) + + def operation(self, operation_id): + assert operation_id == "op-a" + return {"operation_id": "op-a", "app_id": "app-a", "stage": "serving", "payload": {"port": 8080}} + + class Docker: + def inspect_container(self, ref): + assert ref == "container-a" + return deepcopy(raw) + + config = Config(node_id="node-a", data_root=tmp_path, network="bisheng-apps") + executor = DockerExecutor(config, Platform(), Docker()) + service = SimpleNamespace(config=config, platform=executor.platform, docker=executor.docker, runtime=executor) + monkeypatch.setattr(platform_observe, "executor", lambda: service) + monkeypatch.setattr(httpx, "get", lambda *a, **kw: SimpleNamespace(status_code=200)) + return service, raw, scope + + +@pytest.mark.asyncio +async def test_current_http_health_and_real_zero_without_native_health(docker_service): + result = await platform_observe.status("app-a") + assert result["health"] == "healthy" + assert result["restart_count"] == 0 + assert result["started_at"].startswith("2026-10-30T10:33:15.363") + assert result["observation_state"] == "current" + + +@pytest.mark.asyncio +async def test_missing_count_is_unknown_not_zero(docker_service): + _, raw, _ = docker_service + raw.pop("RestartCount") + raw["State"]["StartedAt"] = "0001-01-01T00:00:00Z" + result = await platform_observe.status("app-a") + assert result["restart_count"] is None and result["started_at"] is None + + +@pytest.mark.asyncio +async def test_failed_probe_is_not_healthy(docker_service, monkeypatch): + monkeypatch.setattr(httpx, "get", lambda *a, **kw: SimpleNamespace(status_code=503)) + result = await platform_observe.status("app-a") + assert result["health"] == "unhealthy" + + +@pytest.mark.asyncio +async def test_stopped_execution_is_not_healthy(docker_service): + _, raw, _ = docker_service + raw["State"]["Running"] = False + result = await platform_observe.status("app-a") + assert result["phase"] == "stopped" and result["health"] is None + + +@pytest.mark.asyncio +async def test_no_binding_has_no_historical_facts(docker_service): + _, _, scope = docker_service + scope["exec_ref"] = None + result = await platform_observe.status("app-a") + assert result["observation_state"] == "no_execution" + assert result["instance_id"] is None and result["restart_count"] is None + + +@pytest.mark.asyncio +async def test_changed_binding_cannot_mix_facts(docker_service, monkeypatch): + service, _, _scope = docker_service + previous = service.platform.application + calls = [] + + def application(app_id): + calls.append(app_id) + result = previous(app_id) + if len(calls) > 1: + result["generation"] += 1 + return result + + monkeypatch.setattr(service.platform, "application", application) + with pytest.raises(Exception, match=r"assignment|binding"): + await platform_observe.status("app-a") + + +def test_observe_never_counts_native_health_as_application_success(docker_service, monkeypatch): + service, raw, _ = docker_service + raw["State"]["Health"] = {"Status": "healthy"} + monkeypatch.setattr(httpx, "get", lambda *a, **kw: SimpleNamespace(status_code=503)) + observed = service.runtime.observe("container-a") + assert not observed["ready"] and observed["probe_state"] == "failed" + + +def test_stopped_instance_does_not_keep_a_failed_probe_phase(): + from runtime_manager.observation import public_status + + value = public_status("app-a", {}, {"running": False, "probe_state": "failed"}) + assert value["phase"] == "stopped" and value["health"] is None + + +def test_k8s_status_only_exact_pod_absence_is_missing(monkeypatch): + from runtime_manager.executors.kubernetes import KubernetesExecutor + from runtime_manager.k8s_client import KubernetesAPIError + + executor = object.__new__(KubernetesExecutor) + executor.platform = SimpleNamespace(resource=lambda _: {"resource_ref": "pod-a"}) + + def absent(_): + raise KubernetesAPIError("fixture pod absent", 404) + + monkeypatch.setattr(executor, "_fresh", absent) + assert executor.observe_status("pod-a") is None + monkeypatch.setattr(executor, "_fresh", lambda _: {}) + monkeypatch.setattr(executor, "_observed", lambda *_: absent(None)) + with pytest.raises(KubernetesAPIError): + executor.observe_status("pod-a") + + +@pytest.mark.parametrize( + "app_status,expected_count,expected_time", + [ + ( + {"name": "app", "restartCount": 3, "state": {"running": {"startedAt": "2026-11-01T01:02:03Z"}}}, + 3, + "2026-11-01T01:02:03Z", + ), + ({"name": "app", "restartCount": 0, "state": {}}, 0, None), + ({}, None, None), + ], +) +def test_kubernetes_only_current_app_container(app_status, expected_count, expected_time): + runtime = object.__new__(KubernetesExecutor) + runtime.platform = SimpleNamespace(resources=lambda op: []) + runtime.client = SimpleNamespace() + row = {"resource_ref": "pod-a", "uid": "uid-a", "operation_id": "op-a"} + pod = { + "metadata": {}, + "status": { + "phase": "Running", + "containerStatuses": [app_status], + "initContainerStatuses": [{"name": "init", "restartCount": 99}], + "startTime": "2020-01-01T00:00:00Z", + }, + } + result = runtime._observed(row, pod) + assert result["restart_count"] == expected_count + assert result["started_at"] == expected_time + assert result["probe_state"] == "unknown" diff --git a/src/runtime-manager/tests/test_k8s_observe.py b/src/runtime-manager/tests/test_k8s_observe.py index 01e471a69b..ced49a367a 100644 --- a/src/runtime-manager/tests/test_k8s_observe.py +++ b/src/runtime-manager/tests/test_k8s_observe.py @@ -68,6 +68,9 @@ def list(self, resource, *, label_selector): return {"items": []} class Runtime(KubernetesExecutor): + def observe_status(self, ref): + return self.observe(ref) + def observe(self, ref): return {"exec_ref": ref, "ready": True, "running": True, "phase": "Running", "restart_count": 2} @@ -291,7 +294,12 @@ def inspect_container(self, reference): executions.append(("docker-inspect", reference)) return { "Config": { - "Labels": {"bisheng.runtime.node": "node-a", OP_LABEL: "op-a"}, + "Labels": { + "bisheng.runtime.node": "node-a", + OP_LABEL: "op-a", + "bisheng.app.id": "app-a", + "bisheng.managed": "v2", + }, "Env": ["BISHENG_APP_TOKEN=fixture-business-token"], }, "State": {"Running": healthy, "Health": {"Status": "healthy" if healthy else "unhealthy"}}, @@ -304,10 +312,18 @@ def container_logs(self, reference, **kwargs): return "INFO fixture-business-token\n" class Runtime: + def observe_status(self, reference): + return self.observe(reference) + def observe(self, reference): assert reference == ref executions.append(("k8s-observe", reference)) - return {"ready": healthy, "restart_count": 2} + return { + "running": True, + "ready": healthy, + "probe_state": "passed" if healthy else "failed", + "restart_count": 2, + } def logs(self, reference, **kwargs): assert reference == ref @@ -347,8 +363,13 @@ def test_exact_unassigned_application_is_not_found_over_real_client_and_asgi(obs kind, application=httpx.Response(404, json={"error": {"reason": "application_not_found"}}) ) response = get(suffix) - assert response.status_code == 404 - assert response.json()["detail"]["code"] == "not_found" + if suffix == "status": + assert response.status_code == 200 + assert response.json()["observation_state"] == "no_execution" + assert response.json()["restart_count"] is None + else: + assert response.status_code == 404 + assert response.json()["detail"]["code"] == "not_found" assert [path for method, path in requests if method == "GET"] == [PREFIX + "/apps/app-a/execution"] assert not executions @@ -391,7 +412,7 @@ def test_resource_failures_are_not_application_absence(observed_application_http """覆盖 AC: AC-01、AC-33、AC-35、AC-36、AC-55.""" get, requests, executions, scope = observed_application_http("kubernetes", resource=failure) response = get(suffix) - assert response.status_code == 500 + assert response.status_code == (503 if suffix == "status" else 500) assert "not_found" not in response.text and "private-" not in response.text assert [path for method, path in requests if method == "GET"] == [ PREFIX + "/apps/app-a/execution", @@ -416,6 +437,9 @@ def test_assigned_instance_and_logs_retain_public_shape_with_real_platform_clien "instance_id": scope["exec_ref"], "health": "healthy" if healthy else "unhealthy", "restart_count": 2, + "started_at": None, + "last_probe_at": None, + "observation_state": "current", } logs = get("logs") assert logs.status_code == 200 and set(logs.json()) == {"app_id", "lines"} @@ -432,5 +456,8 @@ def test_assignment_without_execution_still_reports_not_found(observed_applicati kind, application=httpx.Response(200, json={"app_id": "app-a", "node_id": "node-a", "exec_ref": None}) ) response = get(suffix) - assert response.status_code == 404 and response.json()["detail"]["code"] == "not_found" + if suffix == "status": + assert response.status_code == 200 and response.json()["observation_state"] == "no_execution" + else: + assert response.status_code == 404 and response.json()["detail"]["code"] == "not_found" assert not executions From 368894d9a45f265a47903e2b6d08a90f5418b607 Mon Sep 17 00:00:00 2001 From: LineWalker Date: Mon, 2 Nov 2026 15:58:19 +0800 Subject: [PATCH 3/8] fix(open-api): raise task-mode text limit from 20000 to 100000 characters Callers put their built-in prompt template into `text`; a template plus pasted material easily exceeded 20000 characters, while the web task-mode input and the daily-chat open API have no such cap and the storage column is TEXT. Keep a cap against oversized payloads, and document that long reference material belongs in `files`. Co-Authored-By: Claude Opus 5.5 --- features/v3.0.0-beta1/073-openapi-task-mode/design.md | 2 +- .../v3.0.0-beta1/073-openapi-task-mode/task-mode-api.md | 2 +- src/backend/bisheng/open_api/domain/schemas/task_mode.py | 3 ++- src/backend/test/open_api/test_task_mode_schema.py | 7 +++++++ 4 files changed, 11 insertions(+), 3 deletions(-) diff --git a/features/v3.0.0-beta1/073-openapi-task-mode/design.md b/features/v3.0.0-beta1/073-openapi-task-mode/design.md index 0d59de248c..1ef8002e81 100644 --- a/features/v3.0.0-beta1/073-openapi-task-mode/design.md +++ b/features/v3.0.0-beta1/073-openapi-task-mode/design.md @@ -206,7 +206,7 @@ | 字段 / 结构 | 类型 / 格式 | 说明 | 谁会消费 | |---|---|---|---| -| 提交 body | `run_mode:"task"`、`execution:"async"`、`text`、`instructions?`、`model`、`skills?[]`、`tools?[]`、`knowledge_ids?[]`、`knowledge_space_ids?[]`、`files?[]`、`clientTimestamp` | `extra=forbid`;`text` 1–20000 字符;`instructions` ≤ 4000;两类知识各 ≤ 50;`files` 为 `/api/v2/knowledge/upload` 返回的引用 | 调用方 | +| 提交 body | `run_mode:"task"`、`execution:"async"`、`text`、`instructions?`、`model`、`skills?[]`、`tools?[]`、`knowledge_ids?[]`、`knowledge_space_ids?[]`、`files?[]`、`clientTimestamp` | `extra=forbid`;`text` 1–100000 字符;`instructions` ≤ 4000;两类知识各 ≤ 50;`files` 为 `/api/v2/knowledge/upload` 返回的引用 | 调用方 | | 提交响应 `data` | `{task_id, status, queue_position}` | UnifiedResponseModel 信封 | 调用方 | | 任务视图 `data` | `{task_id, status, queue_position?, progress?, partial?, failure?, result?, created_at, updated_at}` | `result` 仅 `completed` 时出现 | 调用方 | | `linsight_session_version.api_meta` | JSON,可空 | `{channel, instructions, credential_id, identity_mode}`;工作台发起的任务为空 | worker、审计排障 | diff --git a/features/v3.0.0-beta1/073-openapi-task-mode/task-mode-api.md b/features/v3.0.0-beta1/073-openapi-task-mode/task-mode-api.md index 4e7f6b0cfe..2deb40b7aa 100644 --- a/features/v3.0.0-beta1/073-openapi-task-mode/task-mode-api.md +++ b/features/v3.0.0-beta1/073-openapi-task-mode/task-mode-api.md @@ -88,7 +88,7 @@ | `run_mode` | 是 | 固定 `"task"` | | `execution` | 是 | 固定 `"async"`;缺省或 `"sync"` 返回 400 / `26060` | | `clientTimestamp` | 是 | 客户端时间,如 `2026-09-30T10:00:00` | -| `text` | 是 | 任务描述(客户应用内置的提示词放这里),1–20000 字符,不能全是空白 | +| `text` | 是 | 任务描述(客户应用内置的提示词放这里),1–100000 字符,不能全是空白;长篇参考材料请用 `files` 上传,不要拼进 `text` | | `instructions` | 否 | 业务上下文指令,≤ 4000 字符;叠加在平台规则之下,不替换平台规则 | | `model` | 是 | 模型 id | | `skills` | 否 | 技能名数组,≤ 50,重复项自动去重;不传则不加载任何技能 | diff --git a/src/backend/bisheng/open_api/domain/schemas/task_mode.py b/src/backend/bisheng/open_api/domain/schemas/task_mode.py index 7bee386070..8ac4b9c7d7 100644 --- a/src/backend/bisheng/open_api/domain/schemas/task_mode.py +++ b/src/backend/bisheng/open_api/domain/schemas/task_mode.py @@ -12,7 +12,8 @@ from bisheng.open_api.domain.schemas.workstation import OpenDailyToolPayload from bisheng.workstation.domain.schemas.chat import APIChatCompletion, UseKnowledgeBaseParam -TASK_TEXT_MAX_LENGTH = 20000 +# Callers put their built-in prompt template here; long reference material belongs in `files`. +TASK_TEXT_MAX_LENGTH = 100000 TASK_INSTRUCTIONS_MAX_LENGTH = 4000 TASK_KNOWLEDGE_MAX_ITEMS = 50 TASK_SKILLS_MAX_ITEMS = 50 diff --git a/src/backend/test/open_api/test_task_mode_schema.py b/src/backend/test/open_api/test_task_mode_schema.py index 37d6854940..2929f38542 100644 --- a/src/backend/test/open_api/test_task_mode_schema.py +++ b/src/backend/test/open_api/test_task_mode_schema.py @@ -51,6 +51,13 @@ def test_text_must_be_present_and_bounded(text): OpenTaskSubmitReq.model_validate(_body(text=text)) +def test_text_accepts_a_long_prompt_up_to_the_limit(): + # A built-in prompt template plus pasted material easily exceeds 20000 characters. + assert TASK_TEXT_MAX_LENGTH >= 100000 + req = OpenTaskSubmitReq.model_validate(_body(text="x" * TASK_TEXT_MAX_LENGTH)) + assert len(req.text) == TASK_TEXT_MAX_LENGTH + + def test_execution_must_be_async(): with pytest.raises(ValidationError): OpenTaskSubmitReq.model_validate(_body(execution="sync")) From f13c2948900c54dc9d25e4a738a42a0caf2c6d84 Mon Sep 17 00:00:00 2001 From: GuoQing Zhang Date: Mon, 2 Nov 2026 16:35:30 +0800 Subject: [PATCH 4/8] fix(permission): preserve versions when reactivating membership sources --- .../design.md | 3 + .../permission/application/control_state.py | 42 ++- .../bisheng/permission/application/runtime.py | 4 + .../test_f048_business_source_projection.py | 5 + .../test_f048_membership_reactivation.py | 240 ++++++++++++++++++ .../test_f048_projection_sql_runtime.py | 1 + 6 files changed, 294 insertions(+), 1 deletion(-) create mode 100644 src/backend/test/permission/test_f048_membership_reactivation.py diff --git a/features/v3.0.0-beta1/048-rebac-permission-model-grants/design.md b/features/v3.0.0-beta1/048-rebac-permission-model-grants/design.md index 8ebf13820d..fe6b9e42fe 100644 --- a/features/v3.0.0-beta1/048-rebac-permission-model-grants/design.md +++ b/features/v3.0.0-beta1/048-rebac-permission-model-grants/design.md @@ -1210,6 +1210,7 @@ domain 对业务 model/repository 的 import 必须由架构测试禁止。正 | 32 | 文件夹本级授权不意味着整个知识空间可见;读取指定文件夹时只要求该容器可见,子项仍逐项执行包含继承的最终判断 | 先校验空间 visible 会使只有某层文件夹权限的账号无法列出或搜索其下级;直接放行全部子项又会越过 CUSTOM 边界 | `KnowledgeSpaceService._require_container_read_permission`,由普通列表与搜索共用;根目录仍校验空间 visible | | 33 | 服务账号继承需要每层资源的 `service_account:*` mode/permission_enabled 技术标记;这些标记不授予任何账号资源权限 | 旧数据只有 `user:*` 时会出现空间 visible 成功而子项不可见、上传失败;新增 Grant 不会补齐存量后代的技术标记 | 创建与迁移编译器写入两类主体标记;模型发布脚本 `publish_authorization_model_change.py` 和完整对账脚本 `reconcile_f048_visible_projection.py` 在维护窗口按 CURRENT 模式补齐,保留 CUSTOM 边界;模型已一致也必须补齐并校验后才返回成功 | | 34 | 预置工具的历史 `user_id` 可以非空;预置身份只由 `GptsToolsType.is_preset=1` 决定;权限 mirror 为 `CURRENT` 也可能缺失系统 tuples | 把创建者为空作为额外条件会从工作台剔除预置工具,并错误地按 USER 迁移;只修分类或只检查 mirror 不能补齐存量系统授权 | 运行时、迁移、清单与租户复制统一判定;显式系统资源审计读取真实 wildcard `visible/system_use_marker`,仅为有效预置工具经版本围栏和 durable ledger 补缺,保留既有 Grant,拒绝 non-CURRENT;预置工具仍只支持 `visible/use` 并经实际动作鉴权 | +| 35 | 成员退出使历史 assignee 版本递增;重新加入会复用稳定来源 ID,不能把来源版本重新设为 1 | SQL assignee 与 visible contribution 版本不一致,投影收尾把实际已写入的可见关系误标为 RETIRED;后续退出漏编译 visible 删除 | `sync_business_source_model` 在编译计划前读取同资源历史来源,按历史版本加 1;`_upsert_assignee` 在资源版本围栏内复核状态与版本,持久化同一版本。空间、频道连续加入/退出、角色移动后重新加入及重复操作由 `test_f048_membership_reactivation.py` 验证;存量孤儿关系经既有维护工具单独对账,不在普通请求中自动清理 | --- @@ -1980,6 +1981,8 @@ D3 已完成全部旧运行数据退役,D6 没有延后的 cleanup 窗口。 ## 修订历史 +- 2026-11-02:修复业务成员来源重新激活时的历史版本同步,写 OpenFGA 前校验版本,保持稳定来源 ID;新增连续加入/退出与角色移动回归。无表结构、公开 API 或权限语义变更,存量孤儿关系仍走独立运维对账。 + - 2026-10-29:修复租户管理员构建列表的跨租户快捷放行;权限冲突码迁至 25020–25026,保留信息源原码及存量资源归属;仅执行本缺陷定向验证。 | 日期 | 改动 | 触发原因 | diff --git a/src/backend/bisheng/permission/application/control_state.py b/src/backend/bisheng/permission/application/control_state.py index 34d945b04c..ee75509352 100644 --- a/src/backend/bisheng/permission/application/control_state.py +++ b/src/backend/bisheng/permission/application/control_state.py @@ -3,7 +3,7 @@ from __future__ import annotations import secrets -from dataclasses import dataclass +from dataclasses import dataclass, replace from sqlalchemy import update from sqlmodel import col, select @@ -384,6 +384,42 @@ async def load_grants( ) return tuple(result) + async def resolve_business_source_version( + self, + *, + target: VerifiedPermissionTarget, + source: GrantSourceRecord, + ) -> GrantSourceRecord: + """Resolve an inactive stable source's next version before planning tuples. + + The resource version fence and _upsert_assignee recheck this snapshot + during prepare, before any OpenFGA write. + """ + async with get_async_db_session() as session: + row = ( + ( + await session.execute( + select(PermissionGrantAssignee) + .join(PermissionGrant, PermissionGrant.id == PermissionGrantAssignee.grant_id) + .where( + PermissionGrant.tenant_id == target.tenant_id, + PermissionGrant.resource_type == target.resource_type, + PermissionGrant.resource_id == target.resource_id, + PermissionGrantAssignee.tenant_id == target.tenant_id, + PermissionGrantAssignee.id == source.source_id, + ) + ) + ) + .scalars() + .first() + ) + if row is None: + return source + historical = self._source_snapshot(row) + if row.state != "INACTIVE" or replace(historical, active=True, version=source.version) != source: + raise PermissionVersionConflictError(msg="Business permission source changed before reactivation") + return replace(source, version=historical.version + 1) + async def load_visible_sources( self, *, @@ -1493,6 +1529,10 @@ async def _upsert_assignee( ) if signature != expected: raise PermissionVersionConflictError(msg="Permission source fingerprint collision") + expected_version = row.version + (1 if row.state == "INACTIVE" else 0) + if source.version != expected_version: + raise PermissionVersionConflictError(msg="Permission source version changed before projection") + row.version = source.version row.state = state return row diff --git a/src/backend/bisheng/permission/application/runtime.py b/src/backend/bisheng/permission/application/runtime.py index d90bcc82b2..8cdb314260 100644 --- a/src/backend/bisheng/permission/application/runtime.py +++ b/src/backend/bisheng/permission/application/runtime.py @@ -1091,6 +1091,10 @@ async def sync_business_source_model( source_fingerprint=source.source_fingerprint, ), ) + stable_source = await self._state.resolve_business_source_version( + target=target, + source=stable_source, + ) changes.append( CanonicalGrantChange( operation="ADD", diff --git a/src/backend/test/permission/test_f048_business_source_projection.py b/src/backend/test/permission/test_f048_business_source_projection.py index f238d1ae9b..13c0cc057a 100644 --- a/src/backend/test/permission/test_f048_business_source_projection.py +++ b/src/backend/test/permission/test_f048_business_source_projection.py @@ -3,6 +3,8 @@ from __future__ import annotations from dataclasses import replace +from types import SimpleNamespace +from unittest.mock import AsyncMock import pytest @@ -69,6 +71,9 @@ def _runtime(context: GrantMutationContext): runtime = object.__new__(F048PermissionRuntime) mutation = _GrantMutation() runtime._grants = mutation + runtime._state = SimpleNamespace( + resolve_business_source_version=AsyncMock(side_effect=lambda **kwargs: kwargs["source"]), + ) async def build_grant_context(*, actor, target): del actor, target diff --git a/src/backend/test/permission/test_f048_membership_reactivation.py b/src/backend/test/permission/test_f048_membership_reactivation.py new file mode 100644 index 0000000000..619441eb4c --- /dev/null +++ b/src/backend/test/permission/test_f048_membership_reactivation.py @@ -0,0 +1,240 @@ +"""Exercise membership reactivation through real SQL state and projection finalizers.""" + +from dataclasses import asdict, replace +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest +from sqlmodel import select + +from bisheng.common.errcode.permission import PermissionVersionConflictError +from bisheng.permission.application import control_state +from bisheng.permission.application.control_state import SqlGrantMutationState, SqlPermissionControlState +from bisheng.permission.application.runtime import F048PermissionRuntime +from bisheng.permission.application.sql_runtime import SqlProjectionFinalizer, SqlProjectionScopeGuard +from bisheng.permission.domain.models import ( + PermissionGrant, + PermissionGrantAssignee, + PermissionVisibleSourceProjection, + ResourcePermissionMode, +) +from bisheng.permission.domain.repositories.projection_repository import ProjectionRepository +from bisheng.permission.domain.schemas import VerifiedPermissionTarget +from bisheng.permission.domain.services.grant_service import GrantService +from bisheng.permission.domain.services.grant_source_service import ( + GrantModelSnapshot, + GrantSnapshot, + GrantSourceService, +) +from bisheng.permission.domain.services.permission_action_service import PermissionActor +from bisheng.permission.domain.services.projection_service import ProjectionService +from bisheng.permission.domain.services.visibility_projection_service import VisibilityProjectionCompiler +from test.permission.test_f048_projection_service import FakeFGAProjection, FakeRecentMarker +from test.permission.test_f048_projection_sql_runtime import session_factory as session_factory +from test.permission.test_f048_projection_sql_runtime import tenant_context as tenant_context + + +@pytest.mark.parametrize( + "model_path", [("viewer", None, "viewer", None, "viewer", None), ("viewer", "manager", None, "viewer", None)] +) +@pytest.mark.parametrize( + "resource_type,source_type", [("knowledge_space", "SPACE_MEMBERSHIP"), ("channel", "CHANNEL_MEMBERSHIP")] +) +async def test_join_leave_rejoin_keeps_versions_and_removes_visibility( + session_factory, monkeypatch, resource_type, source_type, model_path +): + monkeypatch.setattr(control_state, "get_async_db_session", session_factory) + state = SqlPermissionControlState() + # Catalog publication and Redis are outside this regression; SQL grants, + # reservation, tuple ledger and both finalizers use production code. + monkeypatch.setattr(state, "_assert_catalog_writable", AsyncMock()) + owner_visible = ("user:9", "visible", f"{resource_type}:42") + member_visible = ("user:11", "visible", f"{resource_type}:42") + fga = FakeFGAProjection({owner_visible}) + projection = ProjectionService( + repository=ProjectionRepository(session_factory), + marker=FakeRecentMarker(), + scope_guard=SqlProjectionScopeGuard(), + fga=fga, + finalizer=SqlProjectionFinalizer(), + ) + sources = GrantSourceService() + runtime = object.__new__(F048PermissionRuntime) + runtime._state = state + runtime._grants = GrantService(source_service=sources, projection=projection, state=SqlGrantMutationState(state)) + viewer = GrantModelSnapshot(model_key="viewer", active=True, action_codes=("visible", "use"), derived_level=1) + manager = replace(viewer, model_key="manager", derived_level=3) + owner_model = replace(viewer, model_key="owner", derived_level=4) + runtime._runtime_catalog = AsyncMock( + return_value=SimpleNamespace( + release_id=1, + store_id="store", + model_id="model", + models=( + SimpleNamespace(snapshot=viewer), + SimpleNamespace(snapshot=manager), + SimpleNamespace(snapshot=owner_model), + ), + ) + ) + runtime._grant_capabilities = AsyncMock(return_value=()) + actor = PermissionActor(user_id=11, current_tenant_id=7) + async with session_factory() as session: + async with session.begin(): + session.add( + ResourcePermissionMode( + tenant_id=7, + resource_type=resource_type, + resource_id="42", + mode="CUSTOM", + version=1, + projection_state="CURRENT", + ) + ) + + owner = sources.canonicalize_source( + source_id=102, + subject_type="user", + subject_id="9", + source_type="CREATOR", + source_ref=f"{resource_type}:42", + protected=True, + ) + async with session_factory() as session: + async with session.begin(): + owner_grant = PermissionGrant( + tenant_id=7, + resource_type=resource_type, + resource_id="42", + model_key="owner", + state="ACTIVE", + projection_state="CURRENT", + ) + session.add(owner_grant) + await session.flush() + await state._upsert_assignee(session, grant_row=owner_grant, source=owner, state="ACTIVE") + snapshot = GrantSnapshot( + grant_id="owner", + tenant_id=7, + resource_type=resource_type, + resource_id="42", + model=owner_model, + active=True, + sources=(owner,), + ) + contribution = ( + VisibilityProjectionCompiler() + .compile(tenant_id=7, grants=(snapshot,), existing_sources=()) + .active_sources[0] + ) + session.add(PermissionVisibleSourceProjection(**contribution.model_dump())) + + source = sources.canonicalize_source( + source_id=1, subject_type="user", subject_id="11", source_type=source_type, source_ref="42:user:11" + ) + + async def target(): + async with session_factory() as session: + row = (await session.exec(select(ResourcePermissionMode))).one() + return VerifiedPermissionTarget.from_business_service( + tenant_id=7, + resource_type=resource_type, + resource_id="42", + resource_version=row.version, + context_version=f"ctx-{row.version}", + ) + + stable_id = None + for index, model_key in enumerate(model_path): + result = await runtime.sync_business_source_model( + actor=actor, + target=await target(), + source=source, + model_key=model_key, + idempotency_key=f"membership-{index}", + ) + assert result is not None + async with session_factory() as session: + assignee = ( + await session.exec(select(PermissionGrantAssignee).where(PermissionGrantAssignee.subject_id == "11")) + ).one() + visible = list( + ( + await session.exec( + select(PermissionVisibleSourceProjection).where( + PermissionVisibleSourceProjection.projected_subject == "user:11" + ) + ) + ).all() + ) + assert assignee.version == index + 1 + stable_id = stable_id or assignee.id + assert assignee.id == stable_id + if model_key is None: + assert assignee.state == "INACTIVE" + assert all(row.state == "RETIRED" for row in visible) + else: + assert assignee.state == "ACTIVE" + active = [row for row in visible if row.state == "ACTIVE"] + assert len(active) == 1 + assert active[0].model_key == model_key + assert active[0].source_version == assignee.version + assert (member_visible in fga.present) == (model_key is not None) + assert owner_visible in fga.present + async with session_factory() as session: + creator = await session.get(PermissionGrantAssignee, owner.source_id) + assert creator.state == "ACTIVE" and creator.version == 1 + calls = len(fga.calls) + assert ( + await runtime.sync_business_source_model( + actor=actor, + target=await target(), + source=source, + model_key=model_key, + idempotency_key=f"repeat-{index}", + ) + is None + ) + assert len(fga.calls) == calls + + +@pytest.mark.parametrize("source_version,accepted", [(1, False), (2, False), (3, True), (4, False)]) +async def test_inactive_assignee_requires_next_version_before_projection(session_factory, source_version, accepted): + source = GrantSourceService().canonicalize_source( + source_id=101, subject_type="user", subject_id="11", source_type="SPACE_MEMBERSHIP", source_ref="42:user:11" + ) + async with session_factory() as session: + async with session.begin(): + grant = PermissionGrant( + tenant_id=7, + resource_type="knowledge_space", + resource_id="42", + model_key="viewer", + state="INACTIVE", + projection_state="CURRENT", + ) + session.add(grant) + await session.flush() + row = PermissionGrantAssignee( + tenant_id=7, + grant_id=grant.id, + state="INACTIVE", + **{ + key: value for key, value in asdict(source).items() if key not in {"source_id", "active", "version"} + }, + id=101, + version=2, + ) + session.add(row) + await session.flush() + if accepted: + restored = await state_upsert(session, grant, replace(source, version=source_version)) + assert restored.version == 3 + else: + with pytest.raises(PermissionVersionConflictError): + await state_upsert(session, grant, replace(source, version=source_version)) + assert row.version == 2 and row.state == "INACTIVE" + + +async def state_upsert(session, grant, source): + return await SqlPermissionControlState._upsert_assignee(session, grant_row=grant, source=source, state="PENDING") diff --git a/src/backend/test/permission/test_f048_projection_sql_runtime.py b/src/backend/test/permission/test_f048_projection_sql_runtime.py index 9772b3bb15..d32bd340ec 100644 --- a/src/backend/test/permission/test_f048_projection_sql_runtime.py +++ b/src/backend/test/permission/test_f048_projection_sql_runtime.py @@ -70,6 +70,7 @@ async def session_factory( metadata = sa.MetaData() for name in ( "permission_projection_operation", + "permission_projection_tuple", "permission_migration_run", "permission_migration_item", "permission_visible_source_projection", From f1a5162954aa8e62a77fc239b6d7c0a1960a8893 Mon Sep 17 00:00:00 2001 From: GuoQing Zhang Date: Mon, 2 Nov 2026 16:35:21 +0800 Subject: [PATCH 5/8] perf(knowledge): port visible-first managed space batching to release line Adapt the managed-list fix from COFCO a8602ada9 without importing branch-specific info, membership, permission or file-change behavior. Preserve guest access, tenant visibility and service-account handling; add release-line regression coverage. --- .../design.md | 70 ++++ .../e2e-checklist.md | 15 + .../review.md | 23 ++ .../spec.md | 15 + .../tasks.md | 13 + .../validation.md | 65 ++++ .../knowledge_space_listing_repository.py | 42 +++ .../services/knowledge_permission_service.py | 63 ++++ .../services/knowledge_space_service.py | 209 +++++++---- .../application/business_authorization.py | 40 +++ .../permission/application/sql_runtime.py | 32 ++ .../services/permission_action_service.py | 88 +++-- .../test/e2e/test_e2e_managed_spaces.py | 68 ++++ src/backend/test/f040/test_static_scan.py | 13 +- .../test_joined_spaces_permission_sources.py | 36 -- .../test_managed_spaces_visible_edit.py | 337 ++++++++++++++++++ .../test_space_listing_pin_source.py | 17 +- .../test_f048_listing_batch_preparation.py | 178 +++++++++ 18 files changed, 1172 insertions(+), 152 deletions(-) create mode 100644 features/v3.0.0/116-managed-space-list-performance/design.md create mode 100644 features/v3.0.0/116-managed-space-list-performance/e2e-checklist.md create mode 100644 features/v3.0.0/116-managed-space-list-performance/review.md create mode 100644 features/v3.0.0/116-managed-space-list-performance/spec.md create mode 100644 features/v3.0.0/116-managed-space-list-performance/tasks.md create mode 100644 features/v3.0.0/116-managed-space-list-performance/validation.md create mode 100644 src/backend/bisheng/knowledge/domain/repositories/knowledge_space_listing_repository.py create mode 100644 src/backend/test/e2e/test_e2e_managed_spaces.py delete mode 100644 src/backend/test/knowledge/test_joined_spaces_permission_sources.py create mode 100644 src/backend/test/knowledge/test_managed_spaces_visible_edit.py create mode 100644 src/backend/test/permission/test_f048_listing_batch_preparation.py diff --git a/features/v3.0.0/116-managed-space-list-performance/design.md b/features/v3.0.0/116-managed-space-list-performance/design.md new file mode 100644 index 0000000000..e03bd98320 --- /dev/null +++ b/features/v3.0.0/116-managed-space-list-performance/design.md @@ -0,0 +1,70 @@ +# F116 主线设计与分支适配 + +遵守 `docs/constitution.md` C1–C8。来源修复 `a8602ada9`;主线起点 `368894d9a45f265a47903e2b6d08a90f5418b607`。 + +## 1. 背景 + +旧 managed 路径扫描全租户空间,再逐目标准备权限和补充展示数据。普通用户只看少量空间时也承担与租户总空间量相关的开销。采用已评审的可见 ID → edit 批量判断方案。 + +## 2. 范围 + +只迁移 managed 编排、独立只读仓储、容器目标批量构造、统一已验证动作入口及共享批量准备。源分支的其它提交、COFCO 评审材料和部署证据不作为主线代码合并。 + +## 3. 决策与差异 + +| 选择 | 原因/未采用项 | 何时重评 | +|---|---|---| +| complete visible IDs → edit → visible 复核 | 避免全租户扫描;不启用当前策略禁用的动作反向枚举 | 引入正式分页契约或经过评审的动作枚举时 | +| Repository 单次 SQL、IN 分组 500、统一数据库排序 | C1 禁止新增 DAO 入口;Python 排序会改变数据库 collation | 需要跨请求游标分页时 | +| 权限目标/快照/围栏每批 100,Redis 一次 MGET | 仅替换候选来源仍有逐资源 I/O;不引入正权限缓存 | 实测证明批准备仍是瓶颈时 | +| manage_permission 仅补展示角色 | edit-only 不能被管理动作误排除或误标成管理员 | 产品正式改变列表口径时 | + +主线差异逐项保留: + +- 主线没有 COFCO `/info` 优化带来的 `contextmanager` 导入,本次只补该依赖,不同步 `/info` 逻辑。 +- `get_space_info`、广场有效可见性和退出后重新申请的主线实现保持原样。 +- 公开 workflow/assistant 的 guest-link 校验保留;统一动作入口只追加已验证目标批量方法。 +- 主线容器错误码仍为 25022,文件 adapter 的 `rollback_created` 和跨资源类型父子 ID 校验修复保留;不恢复中粮专有的部门 owner/退出来源方法。 +- `runtime.py` 中主线服务账号删除例外、权限发布和生命周期逻辑不修改;共享 decision 批量准备复用现有 runtime 分发。 +- 真实 E2E 使用主线命名与显式 `tenant_id`;旧 `/user/info.tenant_id` 可能为空,不能据此误判返回行跨租户。 + +## 4. 当前实现 + +### 4.1 数据流 + +`actor → 完整 visible IDs → 当前租户 Repository → 批量快照/业务目标 → edit → visible 复核 → 角色补充 → 置顶/计数/部门 → 响应`。 + +全范围超管保留当前租户业务查询。租户管理员即使动作存在身份捷径,也不能跳过个人 visible 范围;收窄 token 继续受统一运行时数据范围约束。 + +### 4.2 字段与内部契约 + +HTTP 路径、参数、响应封装和字段不变。列表入选为 visible ∩ edit;manage_permission 只影响无成员记录时的角色显示。可见枚举上限 5,000,超限报错,不能截断。`knowledge_space_managed` 阶段计时不含网关、依赖注入或响应序列化。 + +### 4.3 模块职责 + +| 模块 | 职责 | +|---|---| +| KnowledgeSpaceService | 范围编排、edit/visible 交集、展示装饰和终态指标 | +| KnowledgeSpaceListingRepository | strict tenant 自动过滤、数据库排序;查询后立即释放连接 | +| KnowledgeContainerDaoPermissionLoader / adapter | 已加载业务行与权限快照组成已验证目标;状态、类型、租户规则不放进权限模块 | +| business_authorization | 分批、动作缺失处理及错误传播,不重复按 ID 查业务行 | +| F048PermissionService / RedisConsistencyMarker | 批量门禁、围栏与最高一致性;固定模型及发布门禁保持 | + +## 5. 已知坑 + +| 事实 | 忽略的后果与处理 | +|---|---| +| patch clean 不表示跨分支依赖齐全 | COFCO 已存在的 contextmanager 未出现在来源 diff;主线显式导入并运行真实 service/ASGI 测试。 | +| Redis amget 丢弃缺失项 | 不按位置对应资源,只判断是否有任一变更标记;异常/缺 sentinel 保持 HIGHER_CONSISTENCY。 | +| visible 与管理身份不同 | 租户管理员不替代可见枚举;超管仅在全范围且当前租户分支保留旧特权列表。 | +| 本地主线已有检索修复与远端重复 | 仅一处注释冲突,采用最新主线注释;整合提交代码树与远端起点完全一致,并保留双方历史。 | + +## 6. 对外契约与验证 + +Outgoing:managed 列表响应;Incoming:当前权限 Catalog/Store/model、完整可见枚举、权限快照、业务行与展示数据。没有新依赖、数据库对象或共享本地状态。 + +基线与适配后在相同环境运行知识/权限/guest/退出重入/检索回归,另有主线 opt-in E2E 和手动清单;真实部署验收单列,不复用 COFCO 的耗时当作主线测量。回滚本修复提交即可。 + +## 修订历史 + +2026-11-02:按用户授权迁移既有方案,记录主线差异、显式依赖与验证边界。 diff --git a/features/v3.0.0/116-managed-space-list-performance/e2e-checklist.md b/features/v3.0.0/116-managed-space-list-performance/e2e-checklist.md new file mode 100644 index 0000000000..2a437e8130 --- /dev/null +++ b/features/v3.0.0/116-managed-space-list-performance/e2e-checklist.md @@ -0,0 +1,15 @@ +# F116 主线部署验收 + +本次只交付本地代码。真实 API 测试:`src/backend/test/e2e/test_e2e_managed_spaces.py`,默认跳过,使用既有测试空间和短期 token,不造数或改授权。 + +明确设置 `MANAGED_SPACES_E2E=1`、`E2E_API_BASE`、`MANAGED_SPACES_E2E_CASES`(JSON)。cases 含 ordinary、super_admin、restricted_super_admin,每项包括 token_env、正整数 tenant_id、以 `e2e-managed-spaces-` 开头的 data_prefix、非空 fixtures。fixtures 含 case_name、space_id、included、user_role、is_pinned;禁止写入真实凭据。 + +在 backend 执行 `pytest test/e2e/test_e2e_managed_spaces.py -v`。收窄超管必须由操作者确认 token 为 personal_only;`/user/info` 的管理员标记本身不能证明 token 数据范围。 + +- [ ] AC-01/AC-04:普通成员、创建者、直接/部门/组编辑授权入选;viewer/manage-only、不可见、未发布、跨租户不入选;验证 my-permissions 的 edit。 +- [ ] AC-02:枚举不完整、不可用和超过 5,000 明确失败,不出现部分成功。 +- [ ] AC-03:固定可见量改变租户总量、固定租户量改变可见量;保存阶段指标及 SQL/FGA 次数,确认没有全租户逐页扫描。 +- [ ] AC-04:租户管理员保持个人 visible 范围;全范围超管仅当前租户;收窄 token 不绕过范围;撤权、发布、Redis 异常不扩大结果。 +- [ ] AC-05:浏览器排序/置顶/角色/计数/部门及详情/广场退出重入不回退;公开应用和文件权限保持主线现状。 +- [ ] AC-06:成功、错误阶段及候选/批次/返回计数可观测。 +- [ ] AC-07:记录实际镜像 digest 和提交,同账号同数据分别测冷/热 P50/P95;离线批次预算和 COFCO 冒烟耗时不是主线实测。 diff --git a/features/v3.0.0/116-managed-space-list-performance/review.md b/features/v3.0.0/116-managed-space-list-performance/review.md new file mode 100644 index 0000000000..9e6e72be89 --- /dev/null +++ b/features/v3.0.0/116-managed-space-list-performance/review.md @@ -0,0 +1,23 @@ +# F116 设计与代码审查 + +依据:当前分支 sdd-review、task-review、sdd-code-review 清单和 `docs/constitution.md`。业务口径及迁移方案已由本会话用户确认,本次只做同一修复的主线适配。 + +## 设计和任务检查 + +Constitution Check:C1 新查询由 Repository 承接,Service 不写 SQL;C2 通用 SQLModel、有界 IN;C3 strict tenant 自动过滤;C4 业务先验证目标,统一权限运行时判定,数据范围先于身份捷径;C5 既有错误码;C6 无凭据;C7 无前端变更;C8 无共享本地文件状态。没有迁移、权限模型发布或运行环境变更。 + +T001:基线和历史检查通过,保护共享工作区既有文件。T002:按补丁语义迁移并补齐主线缺失导入,存量函数范围检查通过。T003:331 项联合回归通过。T004:API ASGI 与真实 E2E/手动清单分离,真实部署未运行。T005:格式/架构/diff 检查通过,按指定路径暂存并交付本地主线。 + +## 七维审查 + +| 维度 | High | Medium | 结果 | +|---|---:|---:|---| +| 边界 | 0 | 0 | 空枚举、5,000 上限、非法 ID、批次边界和失败传播通过。 | +| 权限与认证 | 0 | 0 | 普通用户/租户管理员/收窄 token 不跳过 visible;edit 与管理角色分离,tenant/status/version 保留。 | +| 并发安全 | 0 | 0 | 保留原模型 pin、发布门禁、投影围栏及最高一致性,无正权限缓存或新持久写入;候选查询后释放连接。 | +| 信息泄漏 | 0 | 0 | 指标只增计数/耗时,日志无凭据、SQL 或文件内容,接口字段不变。 | +| 测试覆盖 | 0 | 1 | 基线 253、适配后 331 通过;真实 E2E 3 项和浏览器/双数据库验收待部署后执行。 | +| 风格与范围 | 0 | 0 | Ruff、架构守卫及逐函数 AST 范围检查通过;未恢复 COFCO 定制 owner/退出方法。 | +| 文档同步 | 0 | 0 | 已记录来源、主线差异、导入依赖、字段/容量、角色边界及验证限制。 | + +**Overall: PASS_WITH_WARNINGS**。可交付本地代码;不能据此宣称主线环境已部署或生产性能已改善。 diff --git a/features/v3.0.0/116-managed-space-list-performance/spec.md b/features/v3.0.0/116-managed-space-list-performance/spec.md new file mode 100644 index 0000000000..c4e9ab840f --- /dev/null +++ b/features/v3.0.0/116-managed-space-list-performance/spec.md @@ -0,0 +1,15 @@ +# F116 可编辑知识空间列表性能修复 + +目标:`feat/3.0.0`。日期:2026-11-02。 + +用户已明确授权把本会话中已评审的 managed 修复同步到主线,并要求考虑分支差异。来源:中粮 `a8602ada9`;本文件记录同一已确认方案,不重开产品决策。 + +| AC | 验收标准 | +|---|---| +| AC-01 | `/knowledge/space/managed` 普通用户返回可见且有 edit 的空间;edit-only 入选,manage-only/viewer 不入选,成员角色不直接授予权限。 | +| AC-02 | 先完整获取可见 ID;空集直接返回;流失败、非法 ID、不完整或超过 5,000 上限时明确失败,不退回全租户逐页扫描或部分成功。 | +| AC-03 | 业务行按 ID 有界读取;权限目标每批最多 100 个,快照、Catalog、动作门禁、围栏及一致性标记按批准备,减少逐资源数据库/Redis 往返。 | +| AC-04 | 保留主线租户/状态/版本/投影和数据范围约束;仅全范围超管走当前租户全量路径,租户管理员及收窄 token 保持可见范围约束。 | +| AC-05 | 保留字段、数据库排序、置顶、角色、根文件计数及部门装饰;主线退出重入、详情/广场状态、公开应用访问、文件权限与创建回滚行为不被覆盖。 | +| AC-06 | 新增服务阶段计时、候选/批次/返回数及失败阶段;异常传播,日志不记录凭据、原始 SQL 或文件内容。 | +| AC-07 | 仅同步本修复;不合并中粮分支其它定制代码,不改 schema、索引、权限模型、前端或环境,不自动推送或部署。 | diff --git a/features/v3.0.0/116-managed-space-list-performance/tasks.md b/features/v3.0.0/116-managed-space-list-performance/tasks.md new file mode 100644 index 0000000000..a7730d3bc7 --- /dev/null +++ b/features/v3.0.0/116-managed-space-list-performance/tasks.md @@ -0,0 +1,13 @@ +# F116 同步任务 + +本会话已确认来源方案,并明确授权同步到 `feat/3.0.0`;spec/design 记录已确认内容。 + +- [x] T001 差异审计与基线:核对来源补丁、最新远端、本地主线已有提交及受保护文件;覆盖 AC-04、AC-05、AC-07。 +- [x] T002 迁移实现:仅应用 managed 代码补丁,补齐主线依赖,保留主线知识/权限行为;覆盖 AC-01、AC-02、AC-03、AC-04、AC-06。 +- [x] T003 回归:普通用户与管理员范围、SQL/动作批次预算、ASGI、主线特有 guest/退出重入及检索;覆盖 AC-01、AC-02、AC-03、AC-04、AC-05、AC-06。 +- [x] T004 验收材料:主线命名的 opt-in 真实 API E2E 与手动清单,分离离线与部署证据;覆盖 AC-04、AC-05、AC-07。 +- [x] T005 交付:Ruff、架构守卫、差异与七维审查;仅暂存本任务文件,提交并合入本地主线,保留原文件;覆盖 AC-07。 + +## 实际偏差 + +主线需要显式补 contextmanager;E2E 使用显式预期租户;其余差异见 design.md。未运行真实主线部署测试,本次不自动推送或部署。 diff --git a/features/v3.0.0/116-managed-space-list-performance/validation.md b/features/v3.0.0/116-managed-space-list-performance/validation.md new file mode 100644 index 0000000000..6cd12f66f9 --- /dev/null +++ b/features/v3.0.0/116-managed-space-list-performance/validation.md @@ -0,0 +1,65 @@ +# F116 主线验证记录 + +日期:2026-11-02。来源修复:`a8602ada9effc0718313eb22f66a3c9aef8f358e`。 + +## 基线与分支整合 + +远端主线起点:`368894d9a45f265a47903e2b6d08a90f5418b607`。本地主线另有 `22220ed98` 检索修复;远端已有同一修复的后续版本。隔离工作区以普通合并保留双方历史,唯一冲突是注释;整合提交 `94f3b7b8b` 的代码树与远端起点完全一致。 + +只应用来源提交的 backend 性能代码和回归补丁,未合并 COFCO 其它提交、客户材料或部署配置。真实 E2E 改为主线命名并使用显式预期 tenant_id;主线额外补 contextmanager 导入及租户管理员可见范围回归。 + +## 自动化结果 + +- 变更前同环境基线:**253 passed**。 +- 适配后联合回归:**331 passed、3 skipped**,无失败。跳过项均为默认关闭的真实部署 E2E。 +- 原两个绑定已删除扫描/formatter 的测试移除,其目标改由服务/adapter/统一 runtime 的行为和批次预算测试保护。 +- 新增/修改的 11 个 Python 文件 Ruff check、format check、AST 解析和架构守卫通过;diff 空白及冲突标记检查通过。 +- 函数级 AST 对比确认:存量生产文件只变更 managed 编排/formatter、构造注入,以及批量快照/门禁/标记入口。get_space_info、广场/退出重入、公开应用 guest-link 及文件 adapter 原有方法保持原实现。主线 runtime.py 未修改。 + +使用本机既有 Python 3.11 虚拟环境和临时 SQLite 内存/localhost 不可用 Redis 配置;测试以 fixture 替换外部 I/O,未复制部署凭据、未连接或更改任何运行环境。部分仓储和 Catalog 测试实际执行 SQLite SQL,不能代表 MySQL/DM8 联调。 + +## 联合回归范围 + +```text +test/knowledge/test_knowledge_space_service.py +test/knowledge/test_space_joined_visible_ids.py +test/knowledge/test_space_listing_pin_source.py +test/knowledge/test_knowledge_space_container_access.py +test/knowledge/test_space_preview_access.py +test/knowledge/test_square_revoked_membership.py +test/knowledge/test_knowledge_space_chat_service_retrieve.py +test/knowledge/test_managed_spaces_visible_edit.py +test/permission/test_f048_permission_service.py +test/permission/test_stale_projection_fail_soft.py +test/permission/test_f048_application_adapters.py +test/permission/test_f048_action_catalog_policy.py +test/permission/test_disabled_catalog_action_listing.py +test/permission/test_f048_knowledge_space_adapter.py +test/permission/test_f048_knowledge_file_adapter.py +test/permission/test_f048_consistency_marker.py +test/permission/test_data_scope_enforcement.py +test/permission/test_f048_list_action_cost.py +test/permission/test_f048_catalog_runtime.py +test/permission/test_f048_listing_batch_preparation.py +test/permission/test_creation_rollback.py +test/permission/test_creation_rollback_repository.py +test/f040/test_static_scan.py +test/public_endpoints/test_guest_policy.py +test/public_endpoints/test_guest_link.py +test/open_api/test_service_account_creator_delete.py +test/open_api/test_service_account_absent_resource_grants.py +test/open_api/test_service_account_grant_query.py +test/e2e/test_e2e_managed_spaces.py +``` + +## 已验证与待验收 + +AC-01/AC-02:visible ∩ edit、无成员直接授权、viewer/manage-only 排除;空/错误/超限枚举不回退。 + +AC-03:1/100/101/1,001 可见候选均只调用一次候选仓储;权限快照按 1/1/2/11 批读取,动作与 Redis/围栏准备有界,不对租户总空间逐页展开。这是离线调用预算,不能宣称实际数据库查询总数或性能倍数。 + +AC-04/AC-05:普通用户、租户管理员、全范围/收窄超管,状态/租户/版本围栏及数据库排序、置顶、角色/计数装饰通过;主线专有服务账号创建者删除、回滚、公开访问和退出重入回归通过。 + +AC-06:成功/失败阶段及计数、异常传播通过。AC-07:没有 schema、模型、前端或运行环境变更。 + +真实 JWT/OpenFGA、MySQL/DM8、浏览器及主线性能对比尚未执行,部署后按 [e2e-checklist.md](e2e-checklist.md) 验收。109 COFCO 的冒烟数据不当作本主线的实测结果。 diff --git a/src/backend/bisheng/knowledge/domain/repositories/knowledge_space_listing_repository.py b/src/backend/bisheng/knowledge/domain/repositories/knowledge_space_listing_repository.py new file mode 100644 index 0000000000..b54f3cb76a --- /dev/null +++ b/src/backend/bisheng/knowledge/domain/repositories/knowledge_space_listing_repository.py @@ -0,0 +1,42 @@ +"""Bounded space-list reads, with the connection released before permission I/O.""" + +from collections.abc import Callable +from contextlib import AbstractAsyncContextManager + +from sqlmodel import or_, select +from sqlmodel.ext.asyncio.session import AsyncSession + +from bisheng.core.context.tenant import strict_tenant_filter +from bisheng.core.database import get_async_db_session +from bisheng.knowledge.domain.models.knowledge import Knowledge, KnowledgeTypeEnum + +SessionFactory = Callable[[], AbstractAsyncContextManager[AsyncSession]] + + +class KnowledgeSpaceListingRepository: + def __init__(self, session_factory: SessionFactory | None = None): + self._session_factory = session_factory or get_async_db_session + + async def list_spaces(self, space_ids: list[int] | None, order_by: str = "name") -> list[Knowledge]: + """Load a visible set in database order; None is for full-scope admins. + + IN groups stay below DM8's limit. A single ORDER BY preserves the + existing database collation across groups. Even a global admin's + result stays inside the current tenant. + """ + if space_ids is not None and not space_ids: + return [] + statement = select(Knowledge).where(Knowledge.type == KnowledgeTypeEnum.SPACE.value) + if space_ids is not None: + statement = statement.where( + or_(*(Knowledge.id.in_(space_ids[offset : offset + 500]) for offset in range(0, len(space_ids), 500))) + ) + if order_by == "create_time": + statement = statement.order_by(Knowledge.create_time.desc(), Knowledge.id.desc()) + elif order_by == "name": + statement = statement.order_by(Knowledge.name.asc(), Knowledge.id.asc()) + else: + statement = statement.order_by(Knowledge.update_time.desc(), Knowledge.id.desc()) + with strict_tenant_filter(): + async with self._session_factory() as session: + return list((await session.exec(statement)).all()) diff --git a/src/backend/bisheng/knowledge/domain/services/knowledge_permission_service.py b/src/backend/bisheng/knowledge/domain/services/knowledge_permission_service.py index 05fa730955..be2b5ff213 100644 --- a/src/backend/bisheng/knowledge/domain/services/knowledge_permission_service.py +++ b/src/backend/bisheng/knowledge/domain/services/knowledge_permission_service.py @@ -258,6 +258,10 @@ async def load_permission_record( resource_id: str, ) -> KnowledgeContainerPermissionRecord | None: ... + async def load_permission_records_from_rows( + self, rows: list[Knowledge], *, resource_type: str + ) -> tuple[KnowledgeContainerPermissionRecord, ...]: ... + class KnowledgePermissionVersionPort(Protocol): async def get_permission_version( @@ -325,6 +329,48 @@ async def load_permission_record( context_version=context_version, ) + async def load_permission_records_from_rows( + self, rows: list[Knowledge], *, resource_type: str + ) -> tuple[KnowledgeContainerPermissionRecord, ...]: + """Resolve one business-loaded batch without per-resource SELECTs.""" + allowed_kinds = {"knowledge_space": {"SPACE"}, "knowledge_library": {"NORMAL", "QA"}} + candidates: list[tuple[Knowledge, str, str]] = [] + for row in rows: + if row.id is None or row.tenant_id is None: + continue + try: + kind = KnowledgeTypeEnum(row.type).name + status = KnowledgeState(row.state).name + except ValueError: + continue + if kind in allowed_kinds.get(resource_type, set()): + candidates.append((row, kind, status)) + if not candidates: + return () + keys = tuple((int(row.tenant_id), resource_type, str(row.id)) for row, _, _ in candidates) + snapshots = await self._versions.get_permission_snapshots(keys) + records = [] + for row, kind, status in candidates: + snapshot = snapshots.get((int(row.tenant_id), resource_type, str(row.id))) + if snapshot is None: + continue + context_version = sha256( + (f"{snapshot.context_version}|{row.update_time.isoformat() if row.update_time else '0'}").encode() + ).hexdigest()[:64] + records.append( + KnowledgeContainerPermissionRecord( + tenant_id=int(row.tenant_id), + resource_type=resource_type, + resource_id=str(row.id), + status=status, + kind=kind, + owner_user_id=int(row.user_id or 0), + permission_version=snapshot.version, + context_version=context_version, + ) + ) + return tuple(records) + class KnowledgeContainerPermissionPort(Protocol): async def check_action( @@ -446,6 +492,23 @@ async def resolve_permission_target( ) return self._target(record, actor, resource_type, resource_id) + async def resolve_permission_targets_from_rows( + self, + *, + rows: list[Knowledge], + resource_type: str, + actor: PermissionActor, + ) -> tuple[VerifiedPermissionTarget, ...]: + records = await self._loader.load_permission_records_from_rows(rows, resource_type=resource_type) + targets = [] + for record in records: + try: + targets.append(self._target(record, actor, resource_type, record.resource_id)) + except PermissionInvalidResourceError: + # Listing semantics omit individually invalid business resources. + continue + return tuple(targets) + async def check_action( self, *, diff --git a/src/backend/bisheng/knowledge/domain/services/knowledge_space_service.py b/src/backend/bisheng/knowledge/domain/services/knowledge_space_service.py index edfdfcf1e7..6568d93acc 100644 --- a/src/backend/bisheng/knowledge/domain/services/knowledge_space_service.py +++ b/src/backend/bisheng/knowledge/domain/services/knowledge_space_service.py @@ -3,6 +3,7 @@ import logging import re import tempfile +from contextlib import contextmanager from dataclasses import replace from datetime import datetime from hashlib import sha256 @@ -91,6 +92,7 @@ KnowledgeSpaceTagLibraryDao, ) from bisheng.knowledge.domain.models.knowledge_space_user_pin import KnowledgeSpaceUserPinDao +from bisheng.knowledge.domain.repositories.knowledge_space_listing_repository import KnowledgeSpaceListingRepository from bisheng.knowledge.domain.schemas.knowledge_space_schema import ( FolderUploadItem, InitialPermissionApplyResult, @@ -130,9 +132,11 @@ from bisheng.permission.application.business_authorization import ( batch_check_business_actions, batch_check_business_visible, + batch_check_verified_business_actions, batch_check_verified_business_visible, check_business_action, ) +from bisheng.permission.application.data_scope import DATA_SCOPE_ALL from bisheng.permission.application.identity import resolve_permission_actor from bisheng.permission.application.initial_grant import ( InitialGrantAddition, @@ -207,6 +211,8 @@ _CHILD_PERMISSION_SCAN_BATCH_SIZE = 100 _JOINED_VISIBLE_MAX_RESULTS = 5_000 _JOINED_DB_ID_BATCH_SIZE = 500 +_MANAGED_VISIBLE_MAX_RESULTS = 5_000 +_MANAGED_PERMISSION_BATCH_SIZE = 100 # F040: keyword-search batch-scan window. A batch shrunk by ReBAC visibility # filtering is refilled from the next OFFSET window, so this only bounds per-round # DB fetch + visibility evaluation, not the page size. @@ -233,6 +239,7 @@ def __init__( initial_grant_application: InitialGrantApplication | None = None, prospective_grant_application: ProspectiveGrantApplication | None = None, knowledge_file_repo: "KnowledgeFileRepository | None" = None, + space_listing_repository: KnowledgeSpaceListingRepository | None = None, ): self.request = request self.login_user = login_user @@ -243,6 +250,7 @@ def __init__( self.initial_grant_application = initial_grant_application self.prospective_grant_application = prospective_grant_application self.knowledge_file_repo = knowledge_file_repo + self.space_listing_repository = space_listing_repository or KnowledgeSpaceListingRepository() # Injected by DI factory after construction (same pattern as message_service). # When set, list_space_children will exclude non-primary version files and # return version enrichment fields. @@ -492,50 +500,28 @@ async def _populate_root_file_counts(self, spaces: list[KnowledgeSpaceInfoResp]) for space in spaces: space.file_num = count_map.get(int(space.id), 0) - async def _format_accessible_spaces( + async def _format_managed_space_rows( self, - space_ids: list[int], - order_by: str, + spaces: list[Knowledge], *, - memberships: list[SpaceChannelMember] | None = None, - exclude_created: bool = False, - required_action: str = "visible", + memberships: list[SpaceChannelMember], + action_map: dict[str, frozenset[str]], ) -> list[KnowledgeRead]: - if not space_ids: - return [] - - membership_map = {int(member.business_id): member for member in (memberships or [])} - spaces = await KnowledgeDao.async_get_spaces_by_ids(space_ids, order_by) - if exclude_created: - spaces = [space for space in spaces if space.user_id != self.login_user.user_id] + """Decorate authorized rows in their existing database order.""" if not spaces: return [] + membership_map = {int(member.business_id): member for member in memberships} # Pin state is a per-user preference stored in knowledge_space_user_pin, # decoupled from the membership row (a space may be pinned even when # reached only via ReBAC / department authorization, with no member row). pinned_ids = await KnowledgeSpaceUserPinDao.list_pinned_space_ids(self.login_user.user_id) - requested_actions = tuple( - dict.fromkeys( - ( - "visible", - "manage_permission", - required_action, - ) - ) - ) - action_map = await self._batch_actions( - "knowledge_space", - [space.id for space in spaces], - requested_actions, - ) - pinned_spaces = [] normal_spaces = [] for space in spaces: effective_actions = action_map.get(str(space.id), frozenset()) - if "visible" not in effective_actions or required_action not in effective_actions: + if not {"visible", "edit"}.issubset(effective_actions): continue member_conf = membership_map.get(space.id) result = KnowledgeSpaceInfoResp( @@ -2012,43 +1998,136 @@ async def get_my_created_spaces(self, order_by: str = "update_time") -> list[Kno members = [member for member in members if int(member.business_id) not in department_space_ids] return await self._format_member_spaces(members, order_by) - async def _scan_space_action_ids( - self, - action: str, - ) -> list[int]: - result: list[int] = [] - cursor = None - while True: - rows = await KnowledgeDao.aget_all_knowledge( - knowledge_type=KnowledgeTypeEnum.SPACE, - sort_by="update_time", - limit=100, - cursor=cursor, - ) - if not rows: - break - action_map = await self._batch_actions( - "knowledge_space", - [row.id for row in rows], - (action,), - ) - result.extend(int(row.id) for row in rows if action in action_map.get(str(row.id), frozenset())) - if len(rows) < 100: - break - last = rows[-1] - cursor = [last.update_time, last.id] - return result - async def get_my_managed_spaces(self, order_by: str = "name") -> list[KnowledgeRead]: - members = await SpaceChannelMemberDao.async_get_user_managed_members(self.login_user.user_id) - space_ids = {int(member.business_id) for member in members} - space_ids.update(await self._scan_space_action_ids("manage_permission")) - return await self._format_accessible_spaces( - list(space_ids), - order_by, - memberships=members, - required_action="manage_permission", - ) + """Use visible IDs and bulk edit checks for non-global actors.""" + started = perf_counter() + stage = "actor" + elapsed = dict.fromkeys(("actor", "visible_ids", "candidates", "members", "permission", "metadata"), 0.0) + visible_count = loaded_count = batch_count = 0 + result: list[KnowledgeRead] = [] + status = "error" + error_type = None + system_scope = False + + @contextmanager + def measure(name: str): + nonlocal stage + stage = name + phase_started = perf_counter() + try: + yield + finally: + elapsed[name] += (perf_counter() - phase_started) * 1000 + + try: + with measure("actor"): + actor = await self._permission_actor() + runtime = await get_f048_runtime() + system_scope = actor.super_admin and actor.data_scope == DATA_SCOPE_ALL + visible_ids: list[int] | None = None + if not system_scope: + with measure("visible_ids"): + visible = await runtime.list_visible_objects( + actor, resource_type="knowledge_space", max_results=_MANAGED_VISIBLE_MAX_RESULTS + ) + if visible.resource_type != "knowledge_space" or visible.status != "NORMAL": + raise PermissionEnumerationIncompleteError(msg="Knowledge-space enumeration is incomplete") + try: + visible_ids = list(dict.fromkeys(int(value) for value in visible.object_ids)) + except (TypeError, ValueError) as exc: + raise PermissionEnumerationIncompleteError( + msg="Knowledge-space enumeration returned an invalid ID", exception=exc + ) from exc + if any(str(int(value)) != value or int(value) <= 0 for value in visible.object_ids): + raise PermissionEnumerationIncompleteError( + msg="Knowledge-space enumeration returned an invalid ID" + ) + if len(visible_ids) > _MANAGED_VISIBLE_MAX_RESULTS: + raise PermissionEnumerationIncompleteError(msg="Knowledge-space enumeration exceeded capacity") + visible_count = len(visible_ids) + with measure("candidates"): + spaces = ( + await self.space_listing_repository.list_spaces(visible_ids, order_by) + if visible_ids is None or visible_ids + else [] + ) + # Defense against stale enumeration IDs and an incorrectly + # scoped business loader, including the global-admin branch. + spaces = [space for space in spaces if space.tenant_id == actor.current_tenant_id] + loaded_count = len(spaces) + with measure("members"): + members = ( + await SpaceChannelMemberDao.async_get_user_managed_members(self.login_user.user_id) + if spaces + else [] + ) + action_map: dict[str, frozenset[str]] = {} + with measure("permission"): + if system_scope: + action_map = { + str(space.id): frozenset(("visible", "edit", "manage_permission")) for space in spaces + } + elif spaces: + adapter = self.f048_permission_adapter or await self._resource_adapter("knowledge_space") + member_ids = {int(member.business_id) for member in members} + rows_by_id = {str(space.id): space for space in spaces} + for offset in range(0, len(spaces), _MANAGED_PERMISSION_BATCH_SIZE): + batch_count += 1 + targets = await adapter.resolve_permission_targets_from_rows( + rows=spaces[offset : offset + _MANAGED_PERMISSION_BATCH_SIZE], + resource_type="knowledge_space", + actor=actor, + ) + edit_map = await batch_check_verified_business_actions( + actor=actor, targets=targets, actions=("edit",) + ) + editable = tuple( + target + for target in targets + if "edit" in edit_map.get((target.resource_type, target.resource_id), frozenset()) + ) + visible_map = await batch_check_verified_business_visible(actor=actor, targets=editable) + allowed = tuple( + target + for target in editable + if visible_map.get((target.resource_type, target.resource_id), False) + ) + # Management is display metadata only. Do not require + # it from an editor or expand it for known member roles. + role_targets = tuple( + target + for target in allowed + if int(target.resource_id) not in member_ids + and rows_by_id[target.resource_id].user_id != self.login_user.user_id + ) + roles = await batch_check_verified_business_actions( + actor=actor, targets=role_targets, actions=("manage_permission",) + ) + for target in allowed: + action_map[target.resource_id] = frozenset(("visible", "edit")) | roles.get( + (target.resource_type, target.resource_id), frozenset() + ) + with measure("metadata"): + result = await self._format_managed_space_rows(spaces, memberships=members, action_map=action_map) + status = "success" + return result + except Exception as exc: + error_type = type(exc).__name__ + raise + finally: + emit_metric( + "knowledge_space_managed", + status=status, + system_scope=system_scope, + visible_count=visible_count, + candidate_count=loaded_count, + batch_count=batch_count, + returned_count=len(result), + total_elapsed_ms=(perf_counter() - started) * 1000, + failed_stage=stage if status == "error" else None, + error_type=error_type, + **{f"{name}_elapsed_ms": value for name, value in elapsed.items()}, + ) async def get_my_followed_spaces(self, order_by: str = "update_time") -> list[KnowledgeRead]: """Return complete personally visible spaces created by another user.""" diff --git a/src/backend/bisheng/permission/application/business_authorization.py b/src/backend/bisheng/permission/application/business_authorization.py index f2a658458a..40f1725028 100644 --- a/src/backend/bisheng/permission/application/business_authorization.py +++ b/src/backend/bisheng/permission/application/business_authorization.py @@ -288,3 +288,43 @@ async def batch_check_verified_business_visible( for target, is_allowed in zip(batch_targets, allowed, strict=True): result[(target.resource_type, target.resource_id)] = bool(is_allowed) return result + + +@contextual_operation +async def batch_check_verified_business_actions( + *, + actor: PermissionActor, + targets: Iterable[VerifiedPermissionTarget], + actions: Iterable[str], +) -> dict[tuple[str, str], frozenset[str]]: + """Check already business-verified targets without resolving IDs again. + + Disabled Catalog actions are absent from a listing's capability set, just + as in ``batch_check_business_actions``. Runtime and transport failures + continue to propagate; the caller cannot substitute membership roles. + """ + normalized_targets = tuple({(target.resource_type, target.resource_id): target for target in targets}.values()) + normalized_actions = tuple(dict.fromkeys(actions)) + result: dict[tuple[str, str], set[str]] = { + (target.resource_type, target.resource_id): set() for target in normalized_targets + } + if not normalized_targets or not normalized_actions: + return {key: frozenset() for key in result} + runtime = await get_f048_runtime() + for action in normalized_actions: + for offset in range(0, len(normalized_targets), _MAX_BATCH_CHECKS): + batch = normalized_targets[offset : offset + _MAX_BATCH_CHECKS] + try: + allowed = await runtime.batch_check_actions(actor, batch, action) + except InvalidCatalogActionError: + # A Catalog change during a multi-batch list must not retain + # grants collected earlier for an action now unavailable. + for granted_actions in result.values(): + granted_actions.discard(action) + break + if len(allowed) != len(batch): + raise PermissionPublishNotReadyError(msg="Permission batch returned an incomplete result") + for target, is_allowed in zip(batch, allowed, strict=True): + if is_allowed: + result[(target.resource_type, target.resource_id)].add(action) + return {key: frozenset(value) for key, value in result.items()} diff --git a/src/backend/bisheng/permission/application/sql_runtime.py b/src/backend/bisheng/permission/application/sql_runtime.py index 2056b02f5a..9cd92b70ae 100644 --- a/src/backend/bisheng/permission/application/sql_runtime.py +++ b/src/backend/bisheng/permission/application/sql_runtime.py @@ -365,6 +365,38 @@ async def consistency_for( return HIGHER_CONSISTENCY return None + async def consistency_for_batch(self, targets: tuple[VerifiedPermissionTarget, ...]) -> str | None: + """Any recent marker upgrades the entire batch, matching BatchCheck. + + RedisClient.amget omits missing values. We intentionally test only + whether a marker exists; no returned value is mapped back by position. + """ + if not targets: + return None + redis = await get_redis_client() + if await redis.aget(RECENT_MARKER_SENTINEL) != "ready": + await self._ensure_recovery_started(redis=redis) + return HIGHER_CONSISTENCY + keys = [RECENT_CATALOG_MARKER] + for target in targets: + keys.extend( + ( + self._tenant_key(target.tenant_id), + self._resource_key( + tenant_id=target.tenant_id, resource_type=target.resource_type, resource_id=None + ), + self._resource_key( + tenant_id=target.tenant_id, + resource_type=target.resource_type, + resource_id=target.resource_id, + ), + ) + ) + values = await redis.amget(list(dict.fromkeys(keys))) + if values is None: + raise RuntimeError("Consistency marker batch returned no result") + return HIGHER_CONSISTENCY if any(value is not None for value in values) else None + @staticmethod def _tenant_key(tenant_id: int) -> str: return f"{RECENT_MARKER_PREFIX}:{tenant_id}:*" diff --git a/src/backend/bisheng/permission/domain/services/permission_action_service.py b/src/backend/bisheng/permission/domain/services/permission_action_service.py index 491b9ecbb3..9090eaf389 100644 --- a/src/backend/bisheng/permission/domain/services/permission_action_service.py +++ b/src/backend/bisheng/permission/domain/services/permission_action_service.py @@ -137,6 +137,8 @@ async def consistency_for( resource_id: str | None, ) -> str | None: ... + async def consistency_for_batch(self, targets: tuple[VerifiedPermissionTarget, ...]) -> str | None: ... + class PermissionFGADecisionPort(Protocol): async def check( @@ -324,6 +326,7 @@ async def batch_check_actions( raise ValueError(f"BatchCheck accepts at most {MAX_BATCH_CHECKS} targets") results: list[bool | None] = [None] * len(targets) unresolved: list[tuple[int, VerifiedPermissionTarget]] = [] + pending: list[tuple[int, VerifiedPermissionTarget]] = [] consistency = None data_scope_denied = await self._data_scope_denied_map(actor, targets) for index, target in enumerate(targets): @@ -340,19 +343,37 @@ async def batch_check_actions( if shortcut is not None: results[index] = shortcut[0] continue - try: - force_higher_consistency = await self._prepare_action_target(target, action) - except PermissionPublishNotReadyError as exc: - results[index] = False - self._handle_stale_projection(target, exc) - continue - target_consistency = await self._consistency( - target, - force_higher_consistency=force_higher_consistency, - ) - if target_consistency == HIGHER_CONSISTENCY: - consistency = HIGHER_CONSISTENCY - unresolved.append((index, target)) + pending.append((index, target)) + + if pending: + await self._catalog.ensure_runtime_ready() + pending_targets = tuple(target for _, target in pending) + fence_batch = getattr(type(self._scope_fence), "ensure_readable_batch", None) + if callable(fence_batch): + fences = await self._scope_fence.ensure_readable_batch(pending_targets) + else: + # Keep compatibility with decision ports that only expose the + # single-target operation; failures remain fail closed. + fences = await asyncio.gather( + *(self._scope_fence.ensure_readable(target) for target in pending_targets), + return_exceptions=True, + ) + if len(fences) != len(pending): + raise PermissionProjectionFailedError(msg="Permission scope fence returned an incomplete result") + consistency_inputs: list[tuple[VerifiedPermissionTarget, bool]] = [] + for (index, target), fence in zip(pending, fences, strict=True): + if isinstance(fence, PermissionPublishNotReadyError): + results[index] = False + self._handle_stale_projection(target, fence) + continue + if isinstance(fence, BaseException): + raise fence + unresolved.append((index, target)) + consistency_inputs.append((target, bool(fence))) + for resource_type in dict.fromkeys(target.resource_type for _, target in unresolved): + if not await self._catalog.is_action_effective(resource_type, action): + raise InvalidCatalogActionError(msg=f"Action {action} is unavailable for {resource_type}") + consistency = await self._batch_consistency(tuple(consistency_inputs)) if unresolved: checks = [ @@ -467,28 +488,15 @@ def emit_batch_metric(status: str, *, allowed_count: int = 0) -> None: consistency_started_at = perf_counter() try: - consistency_results = await asyncio.gather( - *( - self._consistency( - target, - force_higher_consistency=force_higher_consistency, - ) - for _, target, force_higher_consistency in consistency_inputs - ) + consistency = await self._batch_consistency( + tuple((target, force_higher_consistency) for _, target, force_higher_consistency in consistency_inputs) ) except Exception: consistency_elapsed_ms = (perf_counter() - consistency_started_at) * 1000 emit_batch_metric("consistency_error") raise consistency_elapsed_ms = (perf_counter() - consistency_started_at) * 1000 - for (index, target, _), target_consistency in zip( - consistency_inputs, - consistency_results, - strict=True, - ): - if target_consistency == HIGHER_CONSISTENCY: - consistency = HIGHER_CONSISTENCY - unresolved.append((index, target)) + unresolved.extend((index, target) for index, target, _ in consistency_inputs) if unresolved: checks = [ @@ -838,6 +846,28 @@ async def _consistency( target.resource_id, ) + async def _batch_consistency(self, inputs: tuple[tuple[VerifiedPermissionTarget, bool], ...]) -> str | None: + """Select the strongest consistency for one complete BatchCheck.""" + if not inputs: + return None + if any(force for _, force in inputs): + for target, force in inputs: + if force: + await self._consistency(target, force_higher_consistency=True) + return HIGHER_CONSISTENCY + batch_reader = getattr(type(self._marker), "consistency_for_batch", None) + if callable(batch_reader): + try: + consistency = await self._marker.consistency_for_batch(tuple(target for target, _ in inputs)) + if consistency not in (None, HIGHER_CONSISTENCY): + raise ValueError("Unsupported permission consistency marker") + return consistency + except Exception: + logger.exception("Failed to read the F048 batch marker; using higher consistency") + return HIGHER_CONSISTENCY + values = await asyncio.gather(*(self._consistency(target) for target, _ in inputs)) + return HIGHER_CONSISTENCY if HIGHER_CONSISTENCY in values else None + async def _scope_consistency( self, tenant_id: int, diff --git a/src/backend/test/e2e/test_e2e_managed_spaces.py b/src/backend/test/e2e/test_e2e_managed_spaces.py new file mode 100644 index 0000000000..697b01fcc5 --- /dev/null +++ b/src/backend/test/e2e/test_e2e_managed_spaces.py @@ -0,0 +1,68 @@ +"""Opt-in read-only F116 contracts against an upgraded dedicated deployment. + +Fixtures and temporary identity tokens must already exist. This suite neither +deploys code nor creates users, grants, spaces, or files. +""" + +import json +import os + +import httpx +import pytest + +from test.e2e.helpers.api import API_BASE, assert_resp_200 +from test.e2e.helpers.auth import auth_headers + +pytestmark = [ + pytest.mark.e2e, + pytest.mark.skipif( + os.environ.get("MANAGED_SPACES_E2E") != "1", reason="requires an upgraded dedicated release-line deployment" + ), +] + + +@pytest.mark.parametrize("identity", ["ordinary", "super_admin", "restricted_super_admin"]) +async def test_ac01_ac04_ac05_live_managed_contract(identity): + """AC-01/04/05: real identities, expected fixture inclusion, fields and pins.""" + assert os.environ.get("E2E_API_BASE"), "explicit E2E_API_BASE is required" + case = json.loads(os.environ["MANAGED_SPACES_E2E_CASES"])[identity] + assert case["data_prefix"].startswith("e2e-managed-spaces-") + assert case["fixtures"], "fixture assertions must not be empty" + expected_tenant = int(case["tenant_id"]) + assert expected_tenant > 0 + headers = auth_headers(os.environ[case["token_env"]]) + async with httpx.AsyncClient(timeout=60.0) as client: + user = assert_resp_200(await client.get(f"{API_BASE}/user/info", headers=headers)) + if identity == "ordinary": + assert user["role"] != "admin" + assert user["is_global_super"] is False and user["is_child_admin"] is False + else: + assert user["is_global_super"] is True or user["role"] == "admin" + rows = assert_resp_200( + await client.get(f"{API_BASE}/knowledge/space/managed", params={"order_by": "name"}, headers=headers) + ) + assert isinstance(rows, list) + assert len({row["id"] for row in rows}) == len(rows) + # Legacy /user/info can report a null tenant_id; fixtures explicitly + # declare the expected current tenant instead of trusting that field. + if user.get("tenant_id") is not None: + assert user["tenant_id"] == expected_tenant + assert all(row["tenant_id"] == expected_tenant for row in rows) + assert [row["is_pinned"] for row in rows] == sorted((row["is_pinned"] for row in rows), reverse=True) + by_id = {row["id"]: row for row in rows} + for fixture in case["fixtures"]: + space_id = int(fixture["space_id"]) + assert (space_id in by_id) is fixture["included"], fixture["case_name"] + if not fixture["included"]: + continue + row = by_id[space_id] + assert row["name"].startswith(case["data_prefix"]) + assert row["user_role"] == fixture["user_role"] + assert row["is_pinned"] is fixture["is_pinned"] + assert isinstance(row["file_num"], int) + summary = assert_resp_200( + await client.get( + f"{API_BASE}/permissions/resources/knowledge_space/{space_id}/my-permissions", headers=headers + ) + ) + assert "edit" in summary["actions"] diff --git a/src/backend/test/f040/test_static_scan.py b/src/backend/test/f040/test_static_scan.py index 742d475a03..fc477f88ba 100644 --- a/src/backend/test/f040/test_static_scan.py +++ b/src/backend/test/f040/test_static_scan.py @@ -47,16 +47,9 @@ def test_unread_counts_live_on_their_own_endpoint(): assert "/{channel_id}/unread-counts" in src -# ─────────────────────────── B — space plaza ────────────────────────────── - - -def test_format_accessible_spaces_shares_context_and_parallelizes(): - """The candidate page is filtered with one action-native batch.""" - src = _read(_BACKEND / "knowledge" / "domain" / "services" / "knowledge_space_service.py") - fmt = _func_src(src, "_format_accessible_spaces") - assert "_batch_actions" in fmt - assert '"visible"' in fmt - assert "manage_permission" in fmt +# F116 replaces the removed formatter with visible-ID/edit orchestration. +# Runtime I/O-budget and role regressions live in +# test/knowledge/test_managed_spaces_visible_edit.py. # ─────────────────────────── C — lists ──────────────────────────────────── diff --git a/src/backend/test/knowledge/test_joined_spaces_permission_sources.py b/src/backend/test/knowledge/test_joined_spaces_permission_sources.py deleted file mode 100644 index b9c1432a90..0000000000 --- a/src/backend/test/knowledge/test_joined_spaces_permission_sources.py +++ /dev/null @@ -1,36 +0,0 @@ -from types import SimpleNamespace -from unittest.mock import AsyncMock, patch - -from bisheng.knowledge.domain.services.knowledge_space_service import KnowledgeSpaceService - - -class _User: - user_id = 841 - tenant_id = 1 - - def is_admin(self): - return False - - -async def test_visible_square_space_is_included_in_permission_scan(): - service = KnowledgeSpaceService(request=None, login_user=_User()) - released_approval_space = SimpleNamespace( - id=4064, - is_released=True, - auth_type="approval", - ) - - with ( - patch( - "bisheng.knowledge.domain.services.knowledge_space_service.KnowledgeDao.aget_all_knowledge", - new=AsyncMock(return_value=[released_approval_space]), - ), - patch.object( - service, - "_batch_actions", - new=AsyncMock(return_value={"4064": frozenset({"visible"})}), - ), - ): - result = await service._scan_space_action_ids("visible") - - assert result == [4064] diff --git a/src/backend/test/knowledge/test_managed_spaces_visible_edit.py b/src/backend/test/knowledge/test_managed_spaces_visible_edit.py new file mode 100644 index 0000000000..9a477aae07 --- /dev/null +++ b/src/backend/test/knowledge/test_managed_spaces_visible_edit.py @@ -0,0 +1,337 @@ +"""F116 offline contracts; real service/adapter/helper, substituted storage and FGA. + +Counts below are I/O-boundary calls, not production latency measurements. +""" + +from contextlib import ExitStack, asynccontextmanager +from dataclasses import replace +from datetime import datetime +from types import SimpleNamespace +from unittest.mock import AsyncMock, patch + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient +from sqlalchemy.dialects import mysql +from sqlmodel import Session, create_engine + +from bisheng.common.errcode.permission import PermissionEnumerationIncompleteError, PermissionFGAUnavailableError +from bisheng.common.models.space_channel_member import UserRoleEnum +from bisheng.core.context.tenant import ( + bypass_tenant_filter, + current_tenant_id, + set_current_tenant_id, + set_visible_tenant_ids, + visible_tenant_ids, +) +from bisheng.core.database.tenant_filter import register_tenant_filter_events +from bisheng.knowledge.api.dependencies import get_knowledge_space_service +from bisheng.knowledge.domain.models import knowledge as knowledge_module +from bisheng.knowledge.domain.models.knowledge import KnowledgeState, KnowledgeTypeEnum +from bisheng.knowledge.domain.repositories.knowledge_space_listing_repository import KnowledgeSpaceListingRepository +from bisheng.knowledge.domain.services.knowledge_permission_service import ( + F048KnowledgeContainerPermissionAdapter, + KnowledgeContainerDaoPermissionLoader, +) +from bisheng.knowledge.domain.services.knowledge_space_service import KnowledgeSpaceService +from bisheng.permission.application.control_state import PermissionResourceSnapshot +from bisheng.permission.application.data_scope import DATA_SCOPE_ALL, DATA_SCOPE_PERSONAL +from bisheng.permission.domain.schemas import VisibilityEnumerationStatus, VisibleObjectEnumerationResult +from bisheng.permission.domain.services.permission_action_service import PermissionActor +from test.knowledge.test_knowledge_space_service import _make_login_user, _make_member, _make_space + +MODULE = "bisheng.knowledge.domain.services.knowledge_space_service" +BUSINESS = "bisheng.permission.application.business_authorization" + + +@pytest.fixture +def case(): + user = _make_login_user() + actor = PermissionActor(user_id=user.user_id, current_tenant_id=1) + rows = [_make_space(space_id=i, user_id=user.user_id if i == 1 else 99) for i in range(1, 8)] + for row in rows: + row.tenant_id = 1 + grants = { + "edit": {"1", "2", "3", "6", "7"}, + "visible": {"1", "2", "3", "4", "5", "7"}, + "manage_permission": {"2", "5", "7"}, + } + + async def check(_actor, targets, action): + return tuple(target.resource_id in grants[action] for target in targets) + + async def snapshots(keys): + return {key: PermissionResourceSnapshot(*key, 3, "ctx3", "CUSTOM", None, None, "READY") for key in keys} + + versions = SimpleNamespace(get_permission_snapshots=AsyncMock(side_effect=snapshots)) + adapter = F048KnowledgeContainerPermissionAdapter( + loader=KnowledgeContainerDaoPermissionLoader(versions), permission=SimpleNamespace() + ) + service = KnowledgeSpaceService(None, user, f048_permission_adapter=adapter) + runtime = SimpleNamespace( + list_visible_objects=AsyncMock( + return_value=VisibleObjectEnumerationResult( + resource_type="knowledge_space", + object_ids=tuple(str(row.id) for row in rows), + status=VisibilityEnumerationStatus.NORMAL, + max_results=5000, + ) + ), + batch_check_actions=AsyncMock(side_effect=check), + ) + members = [_make_member(user_id=user.user_id, space_id=2, user_role=UserRoleEnum.ADMIN)] + with ExitStack() as stack: + + def mock(path, **kwargs): + return stack.enter_context(patch(f"{MODULE}.{path}", new_callable=AsyncMock, **kwargs)) + + state = SimpleNamespace( + user=user, + actor=actor, + service=service, + rows=rows, + runtime=runtime, + versions=versions, + grants=grants, + actor_mock=stack.enter_context( + patch.object(service, "_permission_actor", new=AsyncMock(return_value=actor)) + ), + candidates=stack.enter_context( + patch.object(service.space_listing_repository, "list_spaces", new=AsyncMock(return_value=rows)) + ), + members=mock("SpaceChannelMemberDao.async_get_user_managed_members", return_value=members), + pins=mock("KnowledgeSpaceUserPinDao.list_pinned_space_ids", return_value={7}), + counts=stack.enter_context(patch.object(service, "_populate_root_file_counts", new=AsyncMock())), + department=stack.enter_context( + patch.object(service, "_decorate_department_metadata", new=AsyncMock(side_effect=lambda items: items)) + ), + metrics=stack.enter_context(patch(f"{MODULE}.emit_metric")), + ) + mock("get_f048_runtime", return_value=runtime) + stack.enter_context(patch(f"{BUSINESS}.get_f048_runtime", new=AsyncMock(return_value=runtime))) + # A reintroduced tenant scan or ID re-resolution must fail this suite. + mock("KnowledgeDao.aget_all_knowledge", side_effect=AssertionError("tenant scan")) + mock("KnowledgeDao.aquery_by_id", side_effect=AssertionError("per-space lookup")) + yield state + + +async def test_ac01_visible_intersect_edit_and_roles_pins_database_order(case): + """AC-01/05: viewer and manage-only excluded; creator/admin/editor retained.""" + result = await case.service.get_my_managed_spaces() + assert [item.id for item in result] == [7, 1, 2, 3] + assert [item.user_role for item in result] == [ + UserRoleEnum.ADMIN, + UserRoleEnum.CREATOR, + UserRoleEnum.ADMIN, + UserRoleEnum.MEMBER, + ] + assert all(item.is_followed for item in result) + case.candidates.assert_awaited_once_with(list(range(1, 8)), "name") + calls = case.runtime.batch_check_actions.await_args_list + assert [(call.args[2], [t.resource_id for t in call.args[1]]) for call in calls] == [ + ("edit", [str(i) for i in range(1, 8)]), + ("visible", ["1", "2", "3", "6", "7"]), + ("manage_permission", ["3", "7"]), + ] + case.counts.assert_awaited_once() + case.department.assert_awaited_once() + + +@pytest.mark.parametrize("count", [1, 100, 101, 1001]) +async def test_ac03_permission_preparation_scales_by_batches_not_tenant_size(case, count): + """AC-03: visible candidates produce bounded batch work; no tenant scan.""" + rows = [_make_space(space_id=i, user_id=99) for i in range(1, count + 1)] + for row in rows: + row.tenant_id = 1 + case.runtime.list_visible_objects.return_value = case.runtime.list_visible_objects.return_value.model_copy( + update={"object_ids": tuple(str(row.id) for row in rows)} + ) + case.candidates.return_value = rows + case.grants["edit"] = {str(row.id) for row in rows} + case.grants["visible"] = case.grants["edit"] + case.members.return_value = [] + result = await case.service.get_my_managed_spaces() + assert len(result) == count + assert case.candidates.await_count == 1 + assert case.versions.get_permission_snapshots.await_count == (count + 99) // 100 + assert case.runtime.batch_check_actions.await_count == 3 * ((count + 99) // 100) + assert all(len(call.args[1]) <= 100 for call in case.runtime.batch_check_actions.await_args_list) + + +async def test_ac02_empty_visible_set_does_no_business_or_permission_io(case): + """AC-02: empty complete enumeration is authoritative.""" + case.runtime.list_visible_objects.return_value = case.runtime.list_visible_objects.return_value.model_copy( + update={"object_ids": ()} + ) + assert await case.service.get_my_managed_spaces() == [] + case.candidates.assert_not_awaited() + case.members.assert_not_awaited() + case.runtime.batch_check_actions.assert_not_awaited() + + +@pytest.mark.parametrize("scope", [DATA_SCOPE_ALL, DATA_SCOPE_PERSONAL]) +async def test_ac04_tenant_admin_keeps_personal_visible_boundary(case, scope): + """AC-04: tenant administration never substitutes for visible enumeration.""" + case.actor_mock.return_value = replace( + case.actor, tenant_admin_tenant_ids=frozenset({case.actor.current_tenant_id}), data_scope=scope + ) + case.runtime.list_visible_objects.return_value = case.runtime.list_visible_objects.return_value.model_copy( + update={"object_ids": ()} + ) + assert await case.service.get_my_managed_spaces() == [] + case.runtime.list_visible_objects.assert_awaited_once() + case.candidates.assert_not_awaited() + case.runtime.batch_check_actions.assert_not_awaited() + + +@pytest.mark.parametrize("bad", ["status", "type", "id", "noncanonical", "capacity", "unavailable"]) +async def test_ac02_incomplete_enumeration_never_falls_back_to_tenant_scan(case, bad): + """AC-02/06: enum errors propagate with a terminal failure metric.""" + value = case.runtime.list_visible_objects.return_value + if bad == "status": + value = value.model_copy(update={"status": "incomplete"}) + elif bad == "type": + value = value.model_copy(update={"resource_type": "knowledge_file"}) + elif bad == "id": + value = value.model_copy(update={"object_ids": ("broken",)}) + elif bad == "noncanonical": + value = value.model_copy(update={"object_ids": ("01",)}) + elif bad == "capacity": + value = value.model_copy(update={"object_ids": tuple(str(i) for i in range(1, 5002))}) + else: + case.runtime.list_visible_objects.side_effect = PermissionFGAUnavailableError() + case.runtime.list_visible_objects.return_value = value + with pytest.raises(PermissionFGAUnavailableError if bad == "unavailable" else PermissionEnumerationIncompleteError): + await case.service.get_my_managed_spaces() + case.candidates.assert_not_awaited() + fields = case.metrics.call_args.kwargs + assert fields["status"] == "error" and fields["failed_stage"] == "visible_ids" + assert fields["total_elapsed_ms"] >= fields["visible_ids_elapsed_ms"] >= 0 + + +@pytest.mark.parametrize("scope", [DATA_SCOPE_ALL, DATA_SCOPE_PERSONAL]) +async def test_ac04_super_admin_only_bypasses_enumeration_with_full_scope(case, scope): + """AC-04: restricted system tokens still enumerate and check their scope.""" + case.actor_mock.return_value = replace(case.actor, super_admin=True, data_scope=scope) + case.candidates.return_value.append(_make_space(space_id=999)) # missing tenant, exclude defensively + result = await case.service.get_my_managed_spaces() + assert all(item.id != 999 for item in result) + if scope == DATA_SCOPE_ALL: + case.runtime.list_visible_objects.assert_not_awaited() + case.runtime.batch_check_actions.assert_not_awaited() + case.candidates.assert_awaited_once_with(None, "name") + else: + case.runtime.list_visible_objects.assert_awaited_once() + assert case.runtime.batch_check_actions.await_count > 0 + + +@pytest.mark.parametrize("stage", ["candidates", "members", "permission", "metadata"]) +async def test_ac06_stage_errors_propagate_and_emit_terminal_metric(case, stage): + """AC-06: failed phases do not turn into partial successful lists.""" + failing = { + "candidates": case.candidates, + "members": case.members, + "permission": case.runtime.batch_check_actions, + "metadata": case.counts, + }[stage] + failing.side_effect = RuntimeError("injected phase failure") + with pytest.raises(RuntimeError, match="phase failure"): + await case.service.get_my_managed_spaces() + case.metrics.assert_called_once() + assert case.metrics.call_args.kwargs["failed_stage"] == stage + assert case.metrics.call_args.kwargs["error_type"] == "RuntimeError" + + +def test_ac05_route_serialization_with_real_service(case): + """AC-05: actual /managed route returns the unchanged response envelope.""" + from bisheng.knowledge.api.endpoints.knowledge_space import router + + app = FastAPI() + app.include_router(router, prefix="/api/v1") + app.dependency_overrides[get_knowledge_space_service] = lambda: case.service + with TestClient(app) as client: + response = client.get("/api/v1/knowledge/space/managed?order_by=name") + assert response.status_code == 200 + body = response.json() + assert body["status_code"] == 200 + assert [item["id"] for item in body["data"]] == [7, 1, 2, 3] + assert {"user_role", "subscription_status", "is_pinned", "file_num"} <= body["data"][0].keys() + + +async def test_ac03_repository_uses_bounded_in_lists_and_preserves_db_order(): + """AC-03/05: one SQL statement, <=500 IDs per IN, no application sorting.""" + captured = [] + + async def execute(statement): + captured.append(statement) + return SimpleNamespace(all=lambda: []) + + @asynccontextmanager + async def session(): + yield SimpleNamespace(exec=execute) + + await KnowledgeSpaceListingRepository(session_factory=session).list_spaces(list(range(1, 1002)), "name") + assert len(captured) == 1 + sql = str(captured[0].compile(dialect=mysql.dialect())) + assert sql.count("knowledge.id IN") == 3 + assert "knowledge.type =" in sql + assert "ORDER BY knowledge.name" in sql + assert all(len(values) <= 500 for key, values in captured[0].compile().params.items() if key.startswith("id_")) + + +@pytest.mark.parametrize("ids", [None, [1, 2, 3]]) +async def test_ac04_repository_keeps_global_and_visible_lists_in_current_tenant(ids): + """AC-04: real SQL execution stays strict even with inherited tenant visibility.""" + register_tenant_filter_events() + engine = create_engine("sqlite://") + knowledge_module.Knowledge.__table__.create(engine) + tenant_token = set_current_tenant_id(1) + visibility_token = set_visible_tenant_ids(frozenset({1, 2})) + try: + with Session(engine) as db: + rows = [_make_space(space_id=i) for i in range(1, 4)] + for row in rows: + row.tenant_id = 2 if row.id == 2 else 1 + with bypass_tenant_filter(): + db.add_all(rows) + db.commit() + + async def execute(statement): + return db.exec(statement) + + @asynccontextmanager + async def session(): + yield SimpleNamespace(exec=execute) + + result = await KnowledgeSpaceListingRepository(session_factory=session).list_spaces(ids) + assert {row.id for row in result} == {1, 3} + finally: + visible_tenant_ids.reset(visibility_token) + current_tenant_id.reset(tenant_token) + engine.dispose() + + +async def test_ac04_bulk_adapter_retains_status_tenant_kind_and_snapshot_guards(case): + """AC-04: no extra row loads; stale, unpublished and foreign rows are omitted.""" + rows = [_make_space(space_id=i) for i in range(1, 6)] + for row in rows: + row.tenant_id = 1 + row.update_time = datetime(2026, 11, 2) + rows[1].state = KnowledgeState.FAILED.value + rows[2].tenant_id = 2 + rows[3].type = KnowledgeTypeEnum.NORMAL.value + case.versions.get_permission_snapshots.side_effect = None + case.versions.get_permission_snapshots.return_value = { + (int(row.tenant_id), "knowledge_space", str(row.id)): PermissionResourceSnapshot( + int(row.tenant_id), "knowledge_space", str(row.id), 7, "ctx7", "CUSTOM", None, None, "READY" + ) + for row in rows[:4] + } + adapter = case.service.f048_permission_adapter + targets = await adapter.resolve_permission_targets_from_rows( + rows=rows, resource_type="knowledge_space", actor=case.actor + ) + assert len(targets) == 1 + assert targets[0].resource_id == "1" and targets[0].resource_version == 7 + assert len(targets[0].context_version) == 64 + case.versions.get_permission_snapshots.assert_awaited_once() diff --git a/src/backend/test/knowledge/test_space_listing_pin_source.py b/src/backend/test/knowledge/test_space_listing_pin_source.py index 20de1eb564..ecf976675e 100644 --- a/src/backend/test/knowledge/test_space_listing_pin_source.py +++ b/src/backend/test/knowledge/test_space_listing_pin_source.py @@ -42,21 +42,10 @@ async def test_accessible_spaces_pin_comes_from_pin_table_and_sorts_first(): spaces = [_space(100, "alpha"), _space(200, "beta")] # both creator-owned (user 5) with ( - patch(f"{_KS}.KnowledgeDao.async_get_spaces_by_ids", new=AsyncMock(return_value=spaces)), patch( f"{_KS}.KnowledgeSpaceUserPinDao.list_pinned_space_ids", new=AsyncMock(return_value={200}), ), - patch.object( - service, - "_batch_actions", - new=AsyncMock( - return_value={ - "100": frozenset({"visible"}), - "200": frozenset({"visible"}), - } - ), - ), patch.object( service, "_populate_root_file_counts", @@ -64,7 +53,11 @@ async def test_accessible_spaces_pin_comes_from_pin_table_and_sorts_first(): ), patch.object(service, "_decorate_department_metadata", new=AsyncMock(side_effect=lambda x: x)), ): - result = await service._format_accessible_spaces([100, 200], "name") + result = await service._format_managed_space_rows( + spaces, + memberships=[], + action_map={"100": frozenset({"visible", "edit"}), "200": frozenset({"visible", "edit"})}, + ) assert [r.id for r in result] == [200, 100] # pinned first by_id = {r.id: r.is_pinned for r in result} diff --git a/src/backend/test/permission/test_f048_listing_batch_preparation.py b/src/backend/test/permission/test_f048_listing_batch_preparation.py new file mode 100644 index 0000000000..5becc4cc56 --- /dev/null +++ b/src/backend/test/permission/test_f048_listing_batch_preparation.py @@ -0,0 +1,178 @@ +"""F116 bounded preparation and security gates for verified listing batches.""" + +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + +from bisheng.common.errcode.permission import ( + InvalidCatalogActionError, + PermissionFGAUnavailableError, + PermissionProjectionFailedError, + PermissionPublishNotReadyError, +) +from bisheng.permission.application import business_authorization, sql_runtime +from bisheng.permission.application.business_authorization import batch_check_verified_business_actions +from bisheng.permission.application.sql_runtime import ( + HIGHER_CONSISTENCY, + RECENT_CATALOG_MARKER, + RECENT_MARKER_SENTINEL, + RedisConsistencyMarker, +) +from test.permission.test_f048_permission_service import _actor, _service, _target + + +class BatchMarker: + def __init__(self, *, result=None, error=None): + self.calls = [] + self.result = result + self.error = error + + async def consistency_for_batch(self, targets): + self.calls.append(targets) + if self.error: + raise self.error + return self.result + + +@pytest.mark.parametrize("action", ["edit", "visible"]) +async def test_ac03_one_catalog_fence_marker_and_fga_call_per_100_targets(action): + """AC-03: per-target Catalog/fence/Redis N+1 preparation is removed.""" + service, catalog, fence, _, fga, _, _ = _service() + marker = BatchMarker() + service._marker = marker + targets = tuple(_target(str(i)) for i in range(100)) + if action == "visible": + await service.batch_check_visible(_actor(), targets) + else: + await service.batch_check_actions(_actor(), targets, action) + assert catalog.calls == (["ready", ("action", "workflow", "edit")] if action == "edit" else ["ready"]) + assert fence.calls == [] + assert len(fence.batch_calls) == len(marker.calls) == len(fga.batches) == 1 + assert len(fga.batches[0][0]) == 100 + + +@pytest.mark.parametrize("marker_error", [RuntimeError("redis unavailable"), None]) +async def test_ac04_marker_error_and_recent_changes_require_higher_consistency(marker_error): + """AC-04: batch marker errors never relax consistency or grant permission.""" + service, _, _, _, fga, _, _ = _service() + marker = BatchMarker(result=HIGHER_CONSISTENCY, error=marker_error) + service._marker = marker + assert await service.batch_check_actions(_actor(), (_target("allow"), _target("deny")), "edit") == (True, False) + assert fga.batches[0][1] == HIGHER_CONSISTENCY + + +async def test_ac04_stale_projection_is_excluded_and_recoverable_projection_forces_higher(): + """AC-04: mixed healthy/stale/degraded targets retain individual fence results.""" + service, _, fence, _, fga, _, _ = _service() + marker = BatchMarker() + service._marker = marker + fence.ensure_readable_batch = AsyncMock(return_value=(False, PermissionPublishNotReadyError(), True)) + result = await service.batch_check_actions(_actor(), (_target("allow"), _target("stale"), _target("deny")), "edit") + assert result == (True, False, False) + assert [check["object"] for check in fga.batches[0][0]] == ["workflow:allow", "workflow:deny"] + assert fga.batches[0][1] == HIGHER_CONSISTENCY + assert marker.calls == [] # the entire batch is already forced to higher + + +async def test_ac04_catalog_and_fence_failures_stop_before_openfga(): + """AC-04: unavailable actions and incomplete fence replies fail closed.""" + service, catalog, fence, _, fga, _, _ = _service() + with pytest.raises(InvalidCatalogActionError): + await service.batch_check_actions(_actor(), (_target(),), "download") + assert fga.batches == [] + fence.ensure_readable_batch = AsyncMock(return_value=()) + with pytest.raises(PermissionProjectionFailedError): + await service.batch_check_actions(_actor(), (_target(),), "edit") + assert fga.batches == [] + catalog.ready = False + with pytest.raises(PermissionPublishNotReadyError): + await service.batch_check_actions(_actor(), (_target(),), "edit") + + +async def test_ac04_identity_shortcuts_and_foreign_tenant_skip_batch_io(): + """AC-04: existing identity shortcuts and ordinary tenant denials are retained.""" + service, catalog, fence, _, fga, _, _ = _service() + service._marker = BatchMarker() + assert await service.batch_check_actions(_actor(super_admin=True), (_target(),), "edit") == (True,) + assert await service.batch_check_actions(_actor(), (_target(tenant_id=8),), "edit") == (False,) + assert catalog.calls == fence.calls == fence.batch_calls == fga.batches == [] + + +@pytest.mark.parametrize("action", ["edit", "manage_permission"]) +async def test_ac03_verified_facade_chunks_and_never_resolves_ids_again(monkeypatch, action): + """AC-03: already verified target batches stay bounded and aligned.""" + runtime = SimpleNamespace( + batch_check_actions=AsyncMock( + side_effect=lambda _actor, targets, _action: tuple(t.resource_id != "2" for t in targets) + ) + ) + monkeypatch.setattr(business_authorization, "get_f048_runtime", AsyncMock(return_value=runtime)) + targets = tuple(_target(str(i)) for i in range(201)) + result = await batch_check_verified_business_actions( + actor=_actor(), targets=targets + targets[:1], actions=(action, action) + ) + assert [len(call.args[1]) for call in runtime.batch_check_actions.await_args_list] == [100, 100, 1] + assert result[("workflow", "1")] == {action} + assert result[("workflow", "2")] == frozenset() + + +@pytest.mark.parametrize("error", [InvalidCatalogActionError(), PermissionFGAUnavailableError(), None]) +async def test_ac04_verified_facade_disabled_action_vs_runtime_failure(monkeypatch, error): + """AC-04: disabled actions are absent; runtime/incomplete failures propagate.""" + runtime = SimpleNamespace(batch_check_actions=AsyncMock(return_value=(), side_effect=error)) + monkeypatch.setattr(business_authorization, "get_f048_runtime", AsyncMock(return_value=runtime)) + if isinstance(error, InvalidCatalogActionError): + result = await batch_check_verified_business_actions(actor=_actor(), targets=(_target(),), actions=("edit",)) + assert result[("workflow", "42")] == frozenset() + else: + with pytest.raises(PermissionFGAUnavailableError if error else PermissionPublishNotReadyError): + await batch_check_verified_business_actions(actor=_actor(), targets=(_target(),), actions=("edit",)) + + +async def test_ac04_catalog_action_disabled_mid_list_discards_earlier_batches(monkeypatch): + """AC-04: a disabled action cannot leave a partly authorized result.""" + runtime = SimpleNamespace(batch_check_actions=AsyncMock(side_effect=[(True,) * 100, InvalidCatalogActionError()])) + monkeypatch.setattr(business_authorization, "get_f048_runtime", AsyncMock(return_value=runtime)) + result = await batch_check_verified_business_actions( + actor=_actor(), targets=tuple(_target(str(i)) for i in range(101)), actions=("edit",) + ) + assert all(not actions for actions in result.values()) + + +@pytest.mark.parametrize("scope", ["none", "catalog", "tenant", "type", "resource", "unrelated"]) +async def test_ac03_redis_batch_marker_reads_sentinel_and_one_mget(monkeypatch, scope): + """AC-03/04: missing values may be omitted; only any matching marker matters.""" + marker = RedisConsistencyMarker(window_seconds=30) + values = {RECENT_MARKER_SENTINEL: "ready"} + keys = { + "catalog": RECENT_CATALOG_MARKER, + "tenant": marker._tenant_key(7), + "type": marker._resource_key(tenant_id=7, resource_type="workflow", resource_id=None), + "resource": marker._resource_key(tenant_id=7, resource_type="workflow", resource_id="4"), + "unrelated": marker._resource_key(tenant_id=8, resource_type="workflow", resource_id="4"), + } + if scope != "none": + values[keys[scope]] = "changed" + redis = SimpleNamespace( + aget=AsyncMock(side_effect=values.get), + amget=AsyncMock(side_effect=lambda requested: [values[key] for key in requested if key in values]), + ) + monkeypatch.setattr(sql_runtime, "get_redis_client", AsyncMock(return_value=redis)) + targets = tuple(_target(str(i)) for i in range(100)) + result = await marker.consistency_for_batch(targets) + assert result == (None if scope in {"none", "unrelated"} else HIGHER_CONSISTENCY) + redis.aget.assert_awaited_once_with(RECENT_MARKER_SENTINEL) + redis.amget.assert_awaited_once() + assert len(redis.amget.await_args.args[0]) == 103 # catalog + tenant + type + 100 IDs + + +async def test_ac04_redis_sentinel_recovery_keeps_higher_consistency(monkeypatch): + """AC-04: missing sentinel triggers recovery instead of accepting an empty MGET.""" + marker = RedisConsistencyMarker(window_seconds=30) + marker._ensure_recovery_started = AsyncMock() + redis = SimpleNamespace(aget=AsyncMock(return_value=None), amget=AsyncMock()) + monkeypatch.setattr(sql_runtime, "get_redis_client", AsyncMock(return_value=redis)) + assert await marker.consistency_for_batch((_target(),)) == HIGHER_CONSISTENCY + marker._ensure_recovery_started.assert_awaited_once_with(redis=redis) + redis.amget.assert_not_awaited() From a6ea4a7987e05259f5779f0e011971e7c4c0709c Mon Sep 17 00:00:00 2001 From: GuoQing Zhang Date: Mon, 2 Nov 2026 16:55:31 +0800 Subject: [PATCH 6/8] perf(knowledge): port space info access hints to release line --- .../design.md | 60 +++ .../e2e-checklist.md | 42 ++ .../review.md | 17 + .../117-space-info-read-optimization/spec.md | 14 + .../117-space-info-read-optimization/tasks.md | 12 + .../validation.md | 32 ++ features/v3.0.0/README.md | 2 + .../domain/schemas/knowledge_space_schema.py | 6 +- .../services/knowledge_space_service.py | 209 ++++++---- src/backend/test/e2e/test_e2e_space_info.py | 91 +++++ .../test_knowledge_space_info_optimization.py | 375 ++++++++++++++++++ .../knowledge/test_knowledge_space_service.py | 23 +- .../test_square_revoked_membership.py | 15 +- 13 files changed, 808 insertions(+), 90 deletions(-) create mode 100644 features/v3.0.0/117-space-info-read-optimization/design.md create mode 100644 features/v3.0.0/117-space-info-read-optimization/e2e-checklist.md create mode 100644 features/v3.0.0/117-space-info-read-optimization/review.md create mode 100644 features/v3.0.0/117-space-info-read-optimization/spec.md create mode 100644 features/v3.0.0/117-space-info-read-optimization/tasks.md create mode 100644 features/v3.0.0/117-space-info-read-optimization/validation.md create mode 100644 src/backend/test/e2e/test_e2e_space_info.py create mode 100644 src/backend/test/knowledge/test_knowledge_space_info_optimization.py diff --git a/features/v3.0.0/117-space-info-read-optimization/design.md b/features/v3.0.0/117-space-info-read-optimization/design.md new file mode 100644 index 0000000000..7dad2910df --- /dev/null +++ b/features/v3.0.0/117-space-info-read-optimization/design.md @@ -0,0 +1,60 @@ +# F117 主线设计与分支适配 + +遵守 `docs/constitution.md` C1–C8。主线起点 `cbd755b3ebab083b366922fb54ed545bc94cfccf`,已包含 F116 和成员来源重新激活修复;来源优化 `c4fc2316d`。 + +## 1. 背景 + +`/info` 已执行入口 visible 判断,却又展开全部动作。客户端分享入口只需 visible,按钮通过独立 `my-permissions` 获取,重复展开可从详情读取链路移除。现有统计/装饰查询仍保留,用阶段指标定位其成本。 + +## 2. 范围 + +只修改 `get_space_info`、详情 actions 字段说明及 info/广场共享的有效订阅状态展示函数。DAO、权限运行时、接口入口与前端不修改。不把中粮整段 service 或其它定制合入主线。 + +## 3. 方案与取舍 + +| 决策 | 原因与替代方案 | 何时重评 | +|---|---|---| +| 复用 visible,只输出入口标记 | 不展开全部动作;删除 actions 会破坏分享页判断。按钮继续经统一权限摘要读取。 | 有已确认的其它消费者需要完整动作时 | +| 实际可见优先,撤权清除陈旧已订阅 | 兼容部门/直接授权与主线退出后重入;不能照搬来源的不可见时原样返回 ACTIVE。 | 产品正式改变订阅与有效授权的关系时 | +| 保留所有原有业务查询与执行顺序 | 本次不引入缓存、并行查询或统计口径变化;先消除多余动作展开并提供测量。 | 阶段实测证明还有明确瓶颈时 | +| 记录互不重叠的九个阶段 | 累计 prepare,finally 记录失败时间,异常继续抛出;不把服务计时当作端到端耗时。 | 增加真实独立处理阶段时 | + +## 4. 当前实现 + +### 4.1 数据流 + +`入口加载与 visible → 成员数/成功文件数 → 创建人 → 订阅展示/角色 → visible 标记 → 部门 → 自动标签 → 重建租户检查 → 响应`。 + +入口复用 `_require_space_info_permission`。普通用户 `_has_joined_visibility` 复用已通过的可见性;超管仍查询个人实际可见范围,管理身份不等于加入。角色缺失且有内容权限时仍单次检查 manage_permission。角色只展示,不替代资源动作鉴权。 + +### 4.2 字段与状态约定 + +- `/info.actions`:`["visible"]` 表示可访问内容,`[]` 表示只能预览/申请;其它列表的 actions 语义不在本次调整范围。 +- 实际可见 → subscribed;不可见且历史 subscribed → not_subscribed;不可见的 pending/rejected 保留原展示窗口。成员、审批及授权数据不变。 +- 主线不新增 `download_action_enabled`。文件权限和下载控制仍使用主线实现。 +- `knowledge_space_info` 指标包含 prepare、auth、member_count、file_count、creator、subscription_role、department、auto_tag、rebuild 的 `*_elapsed_ms`,以及 total、失败阶段/异常类型和身份解析计数。 +- total 覆盖服务函数,不包含依赖注入、网关及响应序列化;身份解析耗时属于阶段内开销,不能与阶段耗时重复相加。 + +### 4.3 职责与保留项 + +Service 编排与指标;既有 DAO 读取业务数据;权限 application 继续完成统一鉴权。Schema 只更新已有字段说明,不变更数据库。主线的公开/审批分享预览不要求 is_released,不引入来源分支的广场上架限制。 + +## 5. 已知坑 + +| 分支差异 | 忽略后的后果与处理 | +|---|---| +| 主线已修复撤权后的陈旧 ACTIVE | 直接覆盖来源 helper 会恢复错误的已加入状态;保留不可见时清除 subscribed,并断言 info、广场一致。 | +| 成员角色仍可能残留 | 仅 `member_info.is_active and has_content_permission` 时赋成员角色;无权限预览不返回陈旧成员角色。 | +| 中粮新增下载 Catalog 开关 | 主线没有该字段,不迁移该分支及其阶段,避免改变主线下载契约。 | +| 分享预览与广场上架不同 | 保留主线 `_is_joinable_preview_space`,新增未上架公开/审批空间预览回归。 | +| 旧 AST 隔离测试不执行构造函数 | 为新增计时依赖和实例计数补齐 fixture;另用真实 service 和 ASGI 回归验证,不能仅依赖抽取测试。 | + +## 6. 对外契约与验证 + +Incoming:已有登录身份、业务数据与统一权限决定。Outgoing:详情入口标记、订阅展示、原有统计/装饰与终态指标。没有新端点、模型、依赖或领域所有权变更;不修改 release INV。 + +本机运行知识、权限、managed、撤权重入、检索及公开访问联合回归。真实 JWT/OpenFGA、MySQL/DM8 和浏览器验收通过只读 opt-in 套件及手动清单另验。离线调用预算不作为主线实际提速幅度。 + +## 修订历史 + +2026-11-02:按用户要求适配既有 `/info` 优化,保留主线撤权、角色、分享预览与下载控制差异。 diff --git a/features/v3.0.0/117-space-info-read-optimization/e2e-checklist.md b/features/v3.0.0/117-space-info-read-optimization/e2e-checklist.md new file mode 100644 index 0000000000..c71dfcd695 --- /dev/null +++ b/features/v3.0.0/117-space-info-read-optimization/e2e-checklist.md @@ -0,0 +1,42 @@ +# F117 部署后只读验收 + +状态:待执行。只对明确选定、已升级的专用测试部署运行;本次未部署,不使用 109 中粮结果代替主线验收。 + +## 自动化 + +`test/e2e/test_e2e_space_info.py` 默认跳过。使用已有、名称以 `e2e-space-info-` 开头且 state=1 的空间和临时 JWT;套件只 GET,不创建/删除数据,因此不运行写入式 setup/cleanup。未上架广场的预览 fixture 也必须是 state=1,避免触发旧有重建逻辑。 + +运行前设置: + +```bash +export E2E_API_BASE='https:///api/v1' +export SPACE_INFO_E2E=1 +# SPACE_INFO_E2E_CASES 由操作人提供 JSON;各 token 由 token_env 指定的临时环境变量提供。 +uv run pytest test/e2e/test_e2e_space_info.py -v +``` + +JSON 按以下 case_name 提供对象,每项含 `space_id`、`tenant_id`(当前身份预期租户)、`data_prefix`、`token_env`;成功项另含 `expected_user_role`(可为 null)、`expected_button_actions`。不得把凭据提交到仓库或报告。 + +| case_name | 预期 | +|---|---| +| member / department_grant | 普通成员或无成员记录的授权用户,visible、subscribed | +| pending_visible / rejected_visible | 历史申请中/拒绝但有有效授权,visible、subscribed;记录不被改写 | +| preview_not_joined / stale_active_preview | 无有效授权,空 actions、not_subscribed、空角色 | +| unreleased_preview | is_released=false 的公开/审批分享预览,空 actions、not_subscribed | +| preview_pending / preview_rejected | 不可见,分别 pending/rejected | +| creator | visible、subscribed、creator | +| super_admin_not_joined | 可进入但无个人授权,not_subscribed | +| private_denied / cross_tenant_denied | 分别返回 18040 / 18000 | + +跨租户拒绝 fixture 的资源属于另一租户;case.tenant_id 仍是请求身份当前租户。失败项不检查资源内容。 + +## 手动与观测 + +- [ ] AC-01:普通用户从分享入口进入已授权空间;详情、设置及编辑/删除/分享菜单权限与 my-permissions 一致。 +- [ ] AC-02:有部门/直接授权且历史申请中/拒绝时进入内容页;权限撤回后展示加入页,仍可再次申请。核对成员/审批记录未被详情读取改写。 +- [ ] AC-03:未上架广场的公开/审批分享链接可预览,私有和跨租户拒绝正确。 +- [ ] AC-04:创建者、成员、管理授权无成员、超管未加入,角色/统计/部门/标签正常。 +- [ ] AC-05:按同一 trace 核对 knowledge_space_info 的九阶段、total 及失败阶段;与 HTTP/gateway 时间分别比较,不叠加身份解析耗时。 +- [ ] AC-06:managed、加入/退出和文件下载行为正常;在 MySQL/DM8 的正式回归环境分别验收。 + +不预设毫秒或提速倍数;使用相同账号、空间、数据和负载比较升级前后。 diff --git a/features/v3.0.0/117-space-info-read-optimization/review.md b/features/v3.0.0/117-space-info-read-optimization/review.md new file mode 100644 index 0000000000..caa3910acc --- /dev/null +++ b/features/v3.0.0/117-space-info-read-optimization/review.md @@ -0,0 +1,17 @@ +# F117 代码审查 + +范围:对照已批准的来源方案和主线差异,审查本次改动;基线 cbd755b3e。按项目 sdd-code-review 七维度自审,不宣称独立评审或远端 CI。 + +| 维度 | High | Medium | 结果与证据 | +|---|---|---|---| +| 边界条件 | 0 | 0 | 缺失/私有拒绝、无成员、预览、历史 ACTIVE、pending/rejected、超管与九阶段失败回归通过 | +| 权限与认证 | 0 | 0 | 入口和统一权限运行时不变;展示标记复用已有鉴权,按钮仍独立鉴权;普通用户用例覆盖 | +| 并发安全 | 0 | 0 | 不新增写入、缓存或并发;原业务读取顺序、重建租户条件保持 | +| 信息泄漏 | 0 | 0 | 只记录阶段、数字和异常类名;不记录异常正文、SQL、凭据;schema 只修改说明 | +| 测试覆盖 | 0 | 0 | 392 passed、16 skipped;真实部署项明确未执行,另有只读套件和手动清单 | +| 代码风格 | 0 | 0 | Ruff、F821、AST、架构守卫和 diff 检查通过 | +| 文档同步 | 0 | 0 | spec/design/tasks、状态与字段约定、分支差异、测试边界和部署后清单已记录 | + +函数差异审计确认生产方法仅改 `get_space_info`、`_resolve_effective_subscription_status`;F116、共享权限、撤权后重新申请以及其它并行主线修复均保留。没有新增领域对象或跨模块授权通道,Constitution C1–C8 无新增违规。 + +总体:PASS(本地代码审查)。部署验收及真实性能数据待完成,不作为本次本地同步的已完成事实。 diff --git a/features/v3.0.0/117-space-info-read-optimization/spec.md b/features/v3.0.0/117-space-info-read-optimization/spec.md new file mode 100644 index 0000000000..41684e18ad --- /dev/null +++ b/features/v3.0.0/117-space-info-read-optimization/spec.md @@ -0,0 +1,14 @@ +# F117 知识空间详情读取优化 + +目标:`feat/3.0.0`。日期:2026-11-02。 + +用户已评审中粮 `/info` 方案并要求同步到主线。来源提交 `c4fc2316d791736efca6d8b6001814e474938631`;本次按同一方案适配主线,不重新定义权限模型、成员来源或审批流程。 + +| AC | 验收标准 | +|---|---| +| AC-01 | `/knowledge/space/{id}/info` 复用入口的内容可见性,只返回 `actions=["visible"]` 或 `[]`,不再展开完整动作。编辑、删除、分享等按钮仍从 `my-permissions` 获取。 | +| AC-02 | 普通用户实际可见时展示已订阅,即使历史记录为申请中/拒绝;不修改成员或审批记录。不可见时保留申请状态,并保留主线“历史 ACTIVE 但权限已撤销 → 未订阅、可重新申请”的修复。 | +| AC-03 | 保留主线入口鉴权和分享预览政策:公开/审批空间无需上架广场即可预览;私有空间无权限拒绝;查询不到的空间仍报 18000。鉴权异常传播,不退化为预览。 | +| AC-04 | 保留创建者、有效成员角色、无成员记录时的单次管理权限角色判断,以及超管可访问但未实际加入的区分;统计、部门、自动标签、重建租户保护不变。 | +| AC-05 | 成功/失败各只输出一次终态指标,记录总耗时、各阶段耗时及失败阶段;失败时间计入阶段耗时,不记录 SQL、凭据或异常正文。 | +| AC-06 | 保留主线刚同步的 managed 优化及其它权限修复;不引入中粮专用下载开关,不改数据库、索引、前端或运行环境。真实部署验收独立记录。 | diff --git a/features/v3.0.0/117-space-info-read-optimization/tasks.md b/features/v3.0.0/117-space-info-read-optimization/tasks.md new file mode 100644 index 0000000000..3b6473ed4b --- /dev/null +++ b/features/v3.0.0/117-space-info-read-optimization/tasks.md @@ -0,0 +1,12 @@ +# F117 任务记录 + +- [x] 核对来源 c4fc2316d 与最新远端、本地主线,隔离工作区;保护共享工作区已有文件。 +- [x] 记录同一已批准方案及主线差异;核对客户端分享入口与按钮权限消费者。 +- [x] 移除完整动作展开,补九阶段计时,保留主线入口/角色/租户检查。 +- [x] 适配可见优先状态,并保留不可见 ACTIVE 清除和重新申请行为。 +- [x] 迁移真实 service/ASGI 测试,补主线分支差异及失败阶段回归。 +- [x] 添加默认关闭的只读 E2E 与手动验收清单;本机执行并明确跳过真实部署项。 +- [x] 运行联合回归、Ruff、架构守卫、函数差异审计及七维代码审查。 +- [ ] 部署后的真实权限、双数据库、浏览器与耗时对比验收(本次未更新环境)。 + +适配说明:来源的下载 Catalog 字段/阶段不适用主线,保留主线分享预览政策;不可见的历史 ACTIVE 不能按来源原样返回。详见 design。 diff --git a/features/v3.0.0/117-space-info-read-optimization/validation.md b/features/v3.0.0/117-space-info-read-optimization/validation.md new file mode 100644 index 0000000000..afc2c458ec --- /dev/null +++ b/features/v3.0.0/117-space-info-read-optimization/validation.md @@ -0,0 +1,32 @@ +# F117 主线验证记录 + +日期:2026-11-02。来源 `c4fc2316d791736efca6d8b6001814e474938631`;主线起点 `cbd755b3ebab083b366922fb54ed545bc94cfccf`,已包含 F116、检索会话和成员重新激活修复。 + +## 自动化结果 + +| 验证 | 结果 | +|---|---| +| 修改前 info/广场/预览/容器访问基线 | 64 passed | +| 修改后同范围及新增真实 service/ASGI 回归 | 105 passed | +| 联合知识/权限/managed/撤权重入/检索/公开访问回归 | 392 passed、16 skipped,无失败 | +| 六个变更 Python 文件 | Ruff format/check/F821、AST 解析、架构守卫通过 | +| 函数与 schema 审计 | 存量生产方法只修改 get_space_info 和有效订阅展示函数;schema 字段名/类型不变 | + +16 项跳过均为默认关闭的真实部署 E2E:本功能 13 项,F116 managed 3 项。离线 ASGI 使用真实 router/service,替换登录依赖和 DAO/鉴权 I/O;不代表真实 JWT、OpenFGA 或双数据库联调通过。 + +使用现有 Python 3.11 虚拟环境和临时内存 SQLite/localhost 不可用 Redis 配置,未复制部署凭据、未连接或修改运行环境。现有依赖警告不影响测试通过。 + +## 行为与差异覆盖 + +- AC-01:成员只检查 visible,复用身份,无完整动作展开;无成员记录时保留单次 manage_permission 角色检查。分享页只读 visible;设置页和空间菜单仍读取 my-permissions。 +- AC-02:info 与广场对可见的历史 pending/rejected 展示 subscribed,原记录状态不变;不可见 ACTIVE 清除 subscribed 和陈旧角色,可重新申请。 +- AC-03:缺失/私有拒绝、鉴权异常不降级;主线未上架公开/审批空间的分享预览保留。 +- AC-04:创建者、有效成员、部门/直接授权、超管可进入但未实际加入,以及重建租户保护通过;部门和标签步骤未替换。 +- AC-05:九个阶段逐一注入失败,断言失败耗时被记录、异常传播、一次终态指标;正常响应计数不重复查询,阶段计时不重叠。 +- AC-06:联合回归包含 F116、统一权限与成员重新激活修复。AST 对比确认 managed、入口鉴权、广场方法、加入/退出和其它 service 方法保持主线原实现;生产变更没有 DAO、SQL、索引、权限模型、前端或配置。 + +## 复现证据 + +本会话证据目录 `info-mainline-sync-20261102/` 保存 baseline.json、baseline-regression.txt、focused-regression.txt、regression-paths.json、integrated-regression.txt、static-checks.txt 和 function-scope-audit.json。联合测试路径由上一轮 F116 的已验证集合加本功能两个新测试文件构成。 + +真实权限、MySQL/DM8、浏览器及接口提速幅度尚未实测;按 [e2e-checklist.md](e2e-checklist.md) 部署后验收。本次不推送、不部署。 diff --git a/features/v3.0.0/README.md b/features/v3.0.0/README.md index dad6e01b51..05f737d2da 100644 --- a/features/v3.0.0/README.md +++ b/features/v3.0.0/README.md @@ -1,5 +1,7 @@ # v3.0.0 Feature 索引(应用工场) +2026-11-02 增量:[F117 知识空间详情读取优化](./117-space-info-read-optimization/spec.md) — 按用户要求将已评审的中粮 `/info` 优化适配到主线;保留撤权重入、分享预览和主线下载契约。本地验证完成,真实部署验收待执行,见 [验证记录](./117-space-info-read-optimization/validation.md)。 + **版本目标**:交付《3.0 应用工场 PRD-1》**v2.0**——专业开发者通道(DEV)、应用运行时与发布(RT)、治理与管理面(GOV)共 21 项,及伴生《3.0 开放 API 鉴权与身份传递 PRD》**v2.1** 的 v2 开放 API 鉴权改造。界面承载面落 platform「构建 → 应用」(托管应用 = 与工作流 / 助手并列的第三种类型),运行时 compose / k8s 双形态。 **版本契约**:[release-contract.md](./release-contract.md) diff --git a/src/backend/bisheng/knowledge/domain/schemas/knowledge_space_schema.py b/src/backend/bisheng/knowledge/domain/schemas/knowledge_space_schema.py index c6d16a0734..f34e08087a 100644 --- a/src/backend/bisheng/knowledge/domain/schemas/knowledge_space_schema.py +++ b/src/backend/bisheng/knowledge/domain/schemas/knowledge_space_schema.py @@ -90,7 +90,11 @@ class KnowledgeSpaceInfoResp(KnowledgeBase): user_name: str = Field(default="", description="Knowledge Space creator name") actions: list[str] | None = Field( default=None, - description="Effective F048 actions the current identity holds on this space", + description=( + "On /info, an entry access hint only: ['visible'] for content access, " + "[] for preview-only access. Full actions come from the resource my-permissions endpoint. " + "Other space listings may leave this field unset." + ), ) avatar: str | None = Field(default=None, description="Knowledge Space creator avatar") follower_num: int = Field(1, description="Follower Number") diff --git a/src/backend/bisheng/knowledge/domain/services/knowledge_space_service.py b/src/backend/bisheng/knowledge/domain/services/knowledge_space_service.py index 6568d93acc..80de711e4e 100644 --- a/src/backend/bisheng/knowledge/domain/services/knowledge_space_service.py +++ b/src/backend/bisheng/knowledge/domain/services/knowledge_space_service.py @@ -3,6 +3,7 @@ import logging import re import tempfile +from collections.abc import Iterator from contextlib import contextmanager from dataclasses import replace from datetime import datetime @@ -429,12 +430,11 @@ def _resolve_effective_subscription_status( *, has_visible: bool, ) -> SpaceSubscriptionStatusEnum: - """Resolve joined from current visibility while preserving application states.""" - if subscription_status in { - SpaceSubscriptionStatusEnum.NOT_SUBSCRIBED, - SpaceSubscriptionStatusEnum.SUBSCRIBED, - }: - return SpaceSubscriptionStatusEnum.SUBSCRIBED if has_visible else SpaceSubscriptionStatusEnum.NOT_SUBSCRIBED + """Current visibility wins; revoked access must not retain a stale joined state.""" + if has_visible: + return SpaceSubscriptionStatusEnum.SUBSCRIBED + if subscription_status == SpaceSubscriptionStatusEnum.SUBSCRIBED: + return SpaceSubscriptionStatusEnum.NOT_SUBSCRIBED return subscription_status @staticmethod @@ -1605,86 +1605,137 @@ async def _prospective_creation_access(self): ) async def get_space_info(self, space_id: int) -> KnowledgeSpaceInfoResp: - from bisheng.worker import rebuild_knowledge_celery + request_started_at = perf_counter() + stage = "prepare" + stage_elapsed_ms = dict.fromkeys( + ( + "prepare_elapsed_ms", + "auth_elapsed_ms", + "member_count_elapsed_ms", + "file_count_elapsed_ms", + "creator_elapsed_ms", + "subscription_role_elapsed_ms", + "department_elapsed_ms", + "auto_tag_elapsed_ms", + "rebuild_elapsed_ms", + ), + 0.0, + ) + has_content_permission: bool | None = None - space, has_content_permission = await self._require_space_info_permission(space_id) + @contextmanager + def measure_stage(name: str) -> Iterator[None]: + nonlocal stage + stage = name + started_at = perf_counter() + try: + yield + finally: + # Include the time spent in a stage even when it raises. + stage_elapsed_ms[f"{name}_elapsed_ms"] += (perf_counter() - started_at) * 1000 - follower_num = await SpaceChannelMemberDao.async_count_space_members(space_id) - total_file_num = (await KnowledgeFileDao.async_count_success_files_batch([space_id])).get(space_id, 0) - result = KnowledgeSpaceInfoResp(**space.model_dump()) - if space.user_id != self.login_user.user_id: - create_user = await UserDao.aget_user(space.user_id) - result.user_name = create_user.user_name if create_user else str(space.user_id) - else: - result.user_name = self.login_user.user_name - if space.user_id == self.login_user.user_id: - result.user_role = UserRoleEnum.CREATOR - self._apply_subscription_flags(result, SpaceSubscriptionStatusEnum.SUBSCRIBED) - else: - has_joined_visibility = await self._has_joined_visibility( - space_id, + def emit_info_metric(*, status: str, error_type: str | None = None) -> None: + emit_metric( + "knowledge_space_info", + status=status, + space_id=space_id, has_content_permission=has_content_permission, + failed_stage=stage if status == "error" else None, + error_type=error_type, + actor_resolve_count=self._permission_actor_resolve_count, + actor_cache_hits=self._permission_actor_cache_hits, + actor_resolve_elapsed_ms=self._permission_actor_resolve_elapsed_ms, + total_elapsed_ms=(perf_counter() - request_started_at) * 1000, + **stage_elapsed_ms, ) - subscription_status = SpaceSubscriptionStatusEnum.NOT_SUBSCRIBED - member_info = await SpaceChannelMemberDao.async_find_member( - space_id=space.id, - user_id=self.login_user.user_id, - ) - if member_info: - subscription_status = self._resolve_subscription_status(member_info) - if member_info.is_active and has_content_permission: - result.user_role = member_info.user_role - subscription_status = self._resolve_effective_subscription_status( - subscription_status, - has_visible=has_joined_visibility, - ) - self._apply_subscription_flags(result, subscription_status) - if result.user_role is None and has_content_permission: - result.user_role = ( - UserRoleEnum.ADMIN - if await self._check_action( - "knowledge_space", + + try: + with measure_stage("prepare"): + from bisheng.worker import rebuild_knowledge_celery + + with measure_stage("auth"): + space, has_content_permission = await self._require_space_info_permission(space_id) + + with measure_stage("member_count"): + follower_num = await SpaceChannelMemberDao.async_count_space_members(space_id) + with measure_stage("file_count"): + total_file_num = (await KnowledgeFileDao.async_count_success_files_batch([space_id])).get(space_id, 0) + with measure_stage("prepare"): + result = KnowledgeSpaceInfoResp(**space.model_dump()) + result.follower_num = follower_num + result.file_num = total_file_num + with measure_stage("creator"): + if space.user_id != self.login_user.user_id: + create_user = await UserDao.aget_user(space.user_id) + result.user_name = create_user.user_name if create_user else str(space.user_id) + else: + result.user_name = self.login_user.user_name + + with measure_stage("subscription_role"): + if space.user_id == self.login_user.user_id: + result.user_role = UserRoleEnum.CREATOR + self._apply_subscription_flags(result, SpaceSubscriptionStatusEnum.SUBSCRIBED) + else: + has_joined_visibility = await self._has_joined_visibility( space_id, - "manage_permission", + has_content_permission=has_content_permission, ) - else UserRoleEnum.MEMBER - ) - result.follower_num = follower_num - result.file_num = total_file_num - # The share link has to tell "already has access" from "may only preview - # and apply", and `user_role` cannot: it is absent for a non-member, and - # the client maps an absent role to MEMBER — the same value a real member - # gets. Report the effective actions the way the space list and the - # channel detail already do, so `visible` answers it outright. - # - # Only for a caller who holds the space. The share/square preview deliberately - # answers without `visible` — asking the permission runtime for a viewer - # who has none would both cost a lookup and require a runtime the preview - # path does not depend on. No actions is the honest answer there. - result.actions = ( - sorted(await self._get_effective_actions("knowledge_space", space_id)) if has_content_permission else [] - ) - await self._decorate_department_metadata([result]) - await self._decorate_auto_tag_for_info(result) - - if space.state != KnowledgeState.PUBLISHED.value: - current_tid = get_current_tenant_id() - if space.tenant_id is None or current_tid in (None, space.tenant_id): - rebuild_knowledge_celery.delay( - space_id, - new_model_id=space.model, - invoke_user_id=self.login_user.user_id, - ) - else: - logger.warning( - "skip knowledge space rebuild across tenant boundary: " - "space_id={} space_tenant_id={} current_tenant_id={} user_id={}", - space.id, - space.tenant_id, - current_tid, - self.login_user.user_id, - ) + subscription_status = SpaceSubscriptionStatusEnum.NOT_SUBSCRIBED + member_info = await SpaceChannelMemberDao.async_find_member( + space_id=space.id, + user_id=self.login_user.user_id, + ) + if member_info: + subscription_status = self._resolve_subscription_status(member_info) + if member_info.is_active and has_content_permission: + result.user_role = member_info.user_role + subscription_status = self._resolve_effective_subscription_status( + subscription_status, + has_visible=has_joined_visibility, + ) + self._apply_subscription_flags(result, subscription_status) + if result.user_role is None and has_content_permission: + result.user_role = ( + UserRoleEnum.ADMIN + if await self._check_action( + "knowledge_space", + space_id, + "manage_permission", + ) + else UserRoleEnum.MEMBER + ) + # The share-link guard only needs the entry's visible decision. + # Full button permissions are fetched through my-permissions. + with measure_stage("prepare"): + result.actions = ["visible"] if has_content_permission else [] + with measure_stage("department"): + await self._decorate_department_metadata([result]) + with measure_stage("auto_tag"): + await self._decorate_auto_tag_for_info(result) + + with measure_stage("rebuild"): + if space.state != KnowledgeState.PUBLISHED.value: + current_tid = get_current_tenant_id() + if space.tenant_id is None or current_tid in (None, space.tenant_id): + rebuild_knowledge_celery.delay( + space_id, + new_model_id=space.model, + invoke_user_id=self.login_user.user_id, + ) + else: + logger.warning( + "skip knowledge space rebuild across tenant boundary: " + "space_id={} space_tenant_id={} current_tenant_id={} user_id={}", + space.id, + space.tenant_id, + current_tid, + self.login_user.user_id, + ) + except Exception as exc: + emit_info_metric(status="error", error_type=type(exc).__name__) + raise + emit_info_metric(status="success") return result async def clear_space(self, space_id: int) -> None: diff --git a/src/backend/test/e2e/test_e2e_space_info.py b/src/backend/test/e2e/test_e2e_space_info.py new file mode 100644 index 0000000000..a74d07e64a --- /dev/null +++ b/src/backend/test/e2e/test_e2e_space_info.py @@ -0,0 +1,91 @@ +"""Read-only /info checks against an explicitly selected upgraded test deployment. + +Existing, published fixtures and ephemeral tokens are supplied by the operator. +No users, spaces, permissions, or files are created/deleted by this suite. +See F117's e2e-checklist.md for the case-map contract and execution limits. +""" + +from __future__ import annotations + +import json +import os + +import httpx +import pytest + +from test.e2e.helpers.api import API_BASE, assert_resp_200, assert_resp_error +from test.e2e.helpers.auth import auth_headers + +pytestmark = [ + pytest.mark.e2e, + pytest.mark.skipif( + os.environ.get("SPACE_INFO_E2E") != "1", reason="requires an upgraded dedicated release-line deployment" + ), +] +CASES = ( + ("member", True, "subscribed", "ordinary", 200), + ("department_grant", True, "subscribed", "ordinary", 200), + ("pending_visible", True, "subscribed", "ordinary", 200), + ("rejected_visible", True, "subscribed", "ordinary", 200), + ("preview_not_joined", False, "not_subscribed", "ordinary", 200), + ("stale_active_preview", False, "not_subscribed", "ordinary", 200), + ("unreleased_preview", False, "not_subscribed", "ordinary", 200), + ("preview_pending", False, "pending", "ordinary", 200), + ("preview_rejected", False, "rejected", "ordinary", 200), + ("creator", True, "subscribed", "ordinary", 200), + ("super_admin_not_joined", True, "not_subscribed", "super_admin", 200), + ("private_denied", False, None, "ordinary", 18040), + ("cross_tenant_denied", False, None, "ordinary", 18000), +) + + +@pytest.mark.parametrize("case_name,visible,subscription_status,identity_type,expected_code", CASES) +async def test_ac01_to_ac05_live_info_contract(case_name, visible, subscription_status, identity_type, expected_code): + """AC-01..05: real identity, access hint, subscription, and button summary.""" + assert os.environ.get("E2E_API_BASE"), "explicit E2E_API_BASE is required" + cases = json.loads(os.environ["SPACE_INFO_E2E_CASES"]) + case = cases[case_name] + assert case["data_prefix"].startswith("e2e-space-info-") + expected_tenant = int(case["tenant_id"]) + assert expected_tenant > 0 + token = os.environ[case["token_env"]] + headers = auth_headers(token) + + async with httpx.AsyncClient(timeout=30.0) as client: + identity = assert_resp_200(await client.get(f"{API_BASE}/user/info", headers=headers)) + if identity_type == "ordinary": + assert identity["role"] != "admin" + assert identity["is_global_super"] is False + assert identity["is_child_admin"] is False + elif identity_type == "super_admin": + assert identity["is_global_super"] is True or identity["role"] == "admin" + else: + raise AssertionError("identity must be ordinary or super_admin") + + if identity.get("tenant_id") is not None: + assert identity["tenant_id"] == expected_tenant + space_id = int(case["space_id"]) + response = await client.get(f"{API_BASE}/knowledge/space/{space_id}/info", headers=headers) + if expected_code != 200: + assert_resp_error(response, expected_code) + return + + info = assert_resp_200(response) + assert info["name"].startswith(case["data_prefix"]) + assert info["tenant_id"] == expected_tenant + assert info["user_role"] == case["expected_user_role"] + assert info["state"] == 1, "only pre-provisioned published spaces may be used" + assert info["actions"] == (["visible"] if visible else []) + assert info["subscription_status"] == subscription_status + assert info["is_followed"] is (subscription_status == "subscribed") + assert info["is_pending"] is (subscription_status == "pending") + if case_name == "unreleased_preview": + assert info["is_released"] is False + if visible: + summary = assert_resp_200( + await client.get( + f"{API_BASE}/permissions/resources/knowledge_space/{space_id}/my-permissions", + headers=headers, + ) + ) + assert set(case["expected_button_actions"]) <= set(summary["actions"]) diff --git a/src/backend/test/knowledge/test_knowledge_space_info_optimization.py b/src/backend/test/knowledge/test_knowledge_space_info_optimization.py new file mode 100644 index 0000000000..8d6616dcc8 --- /dev/null +++ b/src/backend/test/knowledge/test_knowledge_space_info_optimization.py @@ -0,0 +1,375 @@ +"""Release-line /info access hints, visibility-first state, and stage diagnostics. + +DAO and authorization I/O are replaced at their boundaries. The real service, +entry gate, actor reuse, joined-state logic and response serialization run. +These are offline regressions, not deployed OpenFGA/DM8 performance evidence. +""" + +from contextlib import ExitStack +from dataclasses import replace +from datetime import datetime +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from bisheng.common.errcode.knowledge_space import SpaceNotFoundError, SpacePermissionDeniedError +from bisheng.common.models.space_channel_member import MembershipStatusEnum, UserRoleEnum +from bisheng.knowledge.api.dependencies import get_knowledge_space_service +from bisheng.knowledge.domain.models.knowledge import AuthTypeEnum, KnowledgeState +from bisheng.knowledge.domain.schemas.knowledge_space_schema import SpaceSubscriptionStatusEnum +from bisheng.knowledge.domain.services.knowledge_space_service import KnowledgeSpaceService +from bisheng.permission.domain.services.permission_action_service import PermissionActor +from test.knowledge.test_knowledge_space_service import _make_login_user, _make_member, _make_space + +MODULE = "bisheng.knowledge.domain.services.knowledge_space_service" +STAGES = ( + "prepare", + "auth", + "member_count", + "file_count", + "creator", + "subscription_role", + "department", + "auto_tag", + "rebuild", +) + + +@pytest.fixture +def info_case(): + login_user = _make_login_user() + space = _make_space(space_id=303, user_id=9, is_released=True, auth_type=AuthTypeEnum.APPROVAL) + actor = PermissionActor(user_id=login_user.user_id, current_tenant_id=login_user.tenant_id) + member = _make_member(user_id=login_user.user_id, space_id=space.id, user_role=UserRoleEnum.MEMBER) + service = KnowledgeSpaceService(MagicMock(), login_user) + + with ExitStack() as stack: + + def mock_async(path, **kwargs): + return stack.enter_context(patch(f"{MODULE}.{path}", new_callable=AsyncMock, **kwargs)) + + mocks = SimpleNamespace( + space=space, + member=member, + service=service, + actor=actor, + query_space=mock_async("KnowledgeDao.aquery_by_id", return_value=space), + check=mock_async("check_business_action", return_value=True), + resolve_actor=mock_async("resolve_permission_actor", return_value=actor), + grant_visible=mock_async("batch_check_business_visible", return_value={"303": False}), + member_count=mock_async("SpaceChannelMemberDao.async_count_space_members", return_value=3), + file_count=mock_async("KnowledgeFileDao.async_count_success_files_batch", return_value={303: 10001}), + creator=mock_async("UserDao.aget_user", return_value=SimpleNamespace(user_name="creator")), + find_member=mock_async("SpaceChannelMemberDao.async_find_member", return_value=member), + runtime=mock_async("get_f048_runtime"), + actions=stack.enter_context(patch.object(service, "_get_effective_actions", new_callable=AsyncMock)), + department=stack.enter_context( + patch.object(service, "_decorate_department_metadata", new_callable=AsyncMock) + ), + auto_tag=stack.enter_context(patch.object(service, "_decorate_auto_tag_for_info", new_callable=AsyncMock)), + metric=stack.enter_context(patch(f"{MODULE}.emit_metric")), + rebuild=stack.enter_context(patch("bisheng.worker.rebuild_knowledge_celery.delay")), + ) + mocks.runtime.return_value = SimpleNamespace(effective_actions=AsyncMock(return_value={"download"})) + yield mocks + + +async def test_ac01_member_reuses_entry_visibility_and_never_expands_actions(info_case): + result = await info_case.service.get_space_info(303) + + assert result.actions == ["visible"] + assert result.user_role == UserRoleEnum.MEMBER + assert result.subscription_status == SpaceSubscriptionStatusEnum.SUBSCRIBED + assert result.follower_num == 3 + assert result.file_num == 10001 + info_case.check.assert_awaited_once_with( + info_case.service.login_user, + resource_type="knowledge_space", + resource_id=303, + action="visible", + actor=info_case.actor, + ) + info_case.actions.assert_not_awaited() + info_case.grant_visible.assert_not_awaited() + info_case.resolve_actor.assert_awaited_once() + info_case.query_space.assert_awaited_once_with(303) + info_case.runtime.assert_not_awaited() + + +@pytest.mark.parametrize("manage_allowed", [False, True]) +async def test_ac01_ac04_department_grant_without_membership_preserves_role_check(info_case, manage_allowed): + info_case.find_member.return_value = None + info_case.check.side_effect = [True, manage_allowed] + + result = await info_case.service.get_space_info(303) + + assert result.actions == ["visible"] + assert result.is_followed is True + assert result.user_role == (UserRoleEnum.ADMIN if manage_allowed else UserRoleEnum.MEMBER) + assert [entry.kwargs["action"] for entry in info_case.check.await_args_list] == ["visible", "manage_permission"] + info_case.actions.assert_not_awaited() + info_case.resolve_actor.assert_awaited_once() + + +@pytest.mark.parametrize("status", [MembershipStatusEnum.PENDING, MembershipStatusEnum.REJECTED]) +async def test_ac02_visible_overrides_old_application_in_info_and_square(info_case, status): + info_case.member.status = status + info_case.member.update_time = datetime.now() + info_case.grant_visible.return_value = {"303": True} + + result = await info_case.service.get_space_info(303) + with ( + patch( + f"{MODULE}.KnowledgeDao.async_get_public_spaces_paginated", + new_callable=AsyncMock, + return_value=[(info_case.space, status, info_case.member.update_time, 3)], + ), + patch(f"{MODULE}.KnowledgeDao.async_count_public_spaces", new_callable=AsyncMock, return_value=1), + patch(f"{MODULE}.UserDao.aget_user_by_ids", new_callable=AsyncMock, return_value=[]), + ): + square = await info_case.service.get_knowledge_square() + + for item in (result, square["data"][0]): + assert item.subscription_status == SpaceSubscriptionStatusEnum.SUBSCRIBED + assert item.is_followed is True + assert item.is_pending is False + # Presentation does not change the approval/member record. + assert info_case.member.status == status + + +@pytest.mark.parametrize("auth_type", [AuthTypeEnum.PUBLIC, AuthTypeEnum.APPROVAL]) +@pytest.mark.parametrize( + "status", [None, MembershipStatusEnum.ACTIVE, MembershipStatusEnum.PENDING, MembershipStatusEnum.REJECTED] +) +async def test_ac03_preview_retains_application_state_without_catalog_lookup(info_case, auth_type, status): + info_case.space.auth_type = auth_type + info_case.check.return_value = False + if status is None: + info_case.find_member.return_value = None + expected = SpaceSubscriptionStatusEnum.NOT_SUBSCRIBED + else: + info_case.member.status = status + info_case.member.update_time = datetime.now() + expected = ( + SpaceSubscriptionStatusEnum.NOT_SUBSCRIBED + if status == MembershipStatusEnum.ACTIVE + else SpaceSubscriptionStatusEnum(status.value.lower()) + ) + + result = await info_case.service.get_space_info(303) + + assert result.actions == [] + assert result.user_role is None + assert result.subscription_status == expected + assert result.is_followed is False + assert result.is_pending == (expected == SpaceSubscriptionStatusEnum.PENDING) + info_case.actions.assert_not_awaited() + info_case.runtime.assert_not_awaited() + assert [entry.kwargs["action"] for entry in info_case.check.await_args_list] == ["visible"] + + +@pytest.mark.parametrize("is_released", [False, True]) +async def test_ac03_private_space_is_denied_before_enrichment(info_case, is_released): + info_case.space.auth_type = AuthTypeEnum.PRIVATE + info_case.space.is_released = is_released + info_case.check.return_value = False + + with pytest.raises(SpacePermissionDeniedError): + await info_case.service.get_space_info(303) + + info_case.member_count.assert_not_awaited() + info_case.runtime.assert_not_awaited() + info_case.actions.assert_not_awaited() + fields = info_case.metric.call_args.kwargs + assert fields["failed_stage"] == "auth" + assert fields["error_type"] == "SpacePermissionDeniedError" + + +async def test_ac04_creator_keeps_role_and_still_checks_entry_access(info_case): + info_case.space.user_id = info_case.service.login_user.user_id + + result = await info_case.service.get_space_info(303) + + assert result.user_role == UserRoleEnum.CREATOR + assert result.actions == ["visible"] + assert result.is_followed is True + info_case.find_member.assert_not_awaited() + info_case.creator.assert_not_awaited() + assert [entry.kwargs["action"] for entry in info_case.check.await_args_list] == ["visible"] + + +async def test_ac04_super_admin_can_enter_without_being_marked_joined(info_case): + actor = replace(info_case.actor, super_admin=True) + info_case.resolve_actor.return_value = actor + info_case.find_member.return_value = None + + result = await info_case.service.get_space_info(303) + + assert result.actions == ["visible"] + assert result.subscription_status == SpaceSubscriptionStatusEnum.NOT_SUBSCRIBED + assert result.is_followed is False + info_case.grant_visible.assert_awaited_once_with( + info_case.service.login_user, + resource_type="knowledge_space", + resource_ids=[303], + actor=actor, + ) + info_case.actions.assert_not_awaited() + + +@pytest.mark.parametrize("auth_type", [AuthTypeEnum.PUBLIC, AuthTypeEnum.APPROVAL]) +async def test_ac03_mainline_unreleased_share_preview_is_preserved(info_case, auth_type): + info_case.space.auth_type = auth_type + info_case.space.is_released = False + info_case.check.return_value = False + info_case.find_member.return_value = None + + result = await info_case.service.get_space_info(303) + + assert result.actions == [] + assert result.subscription_status == SpaceSubscriptionStatusEnum.NOT_SUBSCRIBED + assert result.user_role is None + info_case.actions.assert_not_awaited() + info_case.runtime.assert_not_awaited() + + +@pytest.mark.parametrize("failing_stage", STAGES) +async def test_ac06_stage_metrics_include_failed_time_and_propagate_error(info_case, failing_stage): + clock = [0.0] + + def fail(*args, **kwargs): + clock[0] += 0.25 + raise RuntimeError("offline injected failure") + + fail_at = { + "prepare": None, + "auth": info_case.query_space, + "member_count": info_case.member_count, + "file_count": info_case.file_count, + "creator": info_case.creator, + "subscription_role": info_case.find_member, + "department": info_case.department, + "auto_tag": info_case.auto_tag, + "rebuild": info_case.rebuild, + } + if failing_stage != "prepare": + fail_at[failing_stage].side_effect = fail + if failing_stage == "rebuild": + info_case.space.state = KnowledgeState.REBUILDING.value + with ExitStack() as stack: + stack.enter_context(patch(f"{MODULE}.perf_counter", side_effect=lambda: clock[0])) + if failing_stage == "prepare": + stack.enter_context(patch(f"{MODULE}.KnowledgeSpaceInfoResp", side_effect=fail)) + with pytest.raises(RuntimeError, match="offline injected failure"): + await info_case.service.get_space_info(303) + + info_case.metric.assert_called_once() + assert info_case.metric.call_args.args == ("knowledge_space_info",) + fields = info_case.metric.call_args.kwargs + assert fields["status"] == "error" + assert fields["failed_stage"] == failing_stage + assert fields["error_type"] == "RuntimeError" + assert fields[f"{failing_stage}_elapsed_ms"] == 250.0 + assert fields["total_elapsed_ms"] == 250.0 + + +async def test_ac06_success_metrics_are_bounded_and_counts_are_not_requeried(info_case): + await info_case.service.get_space_info(303) + + info_case.member_count.assert_awaited_once_with(303) + info_case.file_count.assert_awaited_once_with([303]) + info_case.metric.assert_called_once() + fields = info_case.metric.call_args.kwargs + assert fields["status"] == "success" + assert fields["failed_stage"] is None + assert fields["error_type"] is None + elapsed = [fields[f"{stage}_elapsed_ms"] for stage in STAGES] + assert all(value >= 0 for value in elapsed) + assert sum(elapsed) <= fields["total_elapsed_ms"] + assert fields["actor_resolve_count"] == 1 + assert fields["space_id"] == 303 + assert fields["has_content_permission"] is True + assert not {"user_name", "sql", "password", "token"} & fields.keys() + + +@pytest.fixture +def info_client(info_case): + from bisheng.knowledge.api.endpoints.knowledge_space import router + + app = FastAPI() + app.include_router(router, prefix="/api/v1") + # Exercise the real service with isolated I/O; live authentication is covered + # by the opt-in deployment suite, not this dependency override. + app.dependency_overrides[get_knowledge_space_service] = lambda: info_case.service + with TestClient(app) as client: + yield client + + +def test_ac01_api_serializes_visible_hint_without_full_action_lookup(info_client, info_case): + response = info_client.get("/api/v1/knowledge/space/303/info") + + assert response.status_code == 200 + payload = response.json() + assert payload["status_code"] == 200 + assert "status_message" in payload + assert payload["data"]["actions"] == ["visible"] + assert payload["data"]["subscription_status"] == "subscribed" + assert "download_action_enabled" not in payload["data"] + info_case.actions.assert_not_awaited() + + +def test_ac03_api_serializes_preview_without_visible_hint(info_client, info_case): + info_case.check.return_value = False + info_case.find_member.return_value = None + + payload = info_client.get("/api/v1/knowledge/space/303/info").json() + + assert payload["status_code"] == 200 + assert payload["data"]["actions"] == [] + assert payload["data"]["subscription_status"] == "not_subscribed" + + +@pytest.mark.parametrize("missing", [False, True]) +def test_ac03_api_propagates_exact_not_found_and_permission_errors(info_client, info_case, missing): + if missing: + info_case.query_space.return_value = None + else: + info_case.space.auth_type = AuthTypeEnum.PRIVATE + info_case.check.return_value = False + + expected = SpaceNotFoundError if missing else SpacePermissionDeniedError + # This minimal app does not install the production exception middleware. + with pytest.raises(expected) as error: + info_client.get("/api/v1/knowledge/space/303/info") + assert error.value.code == expected.Code + info_case.file_count.assert_not_awaited() + + +async def test_ac03_authorization_failure_never_falls_back_to_public_preview(info_case): + info_case.check.side_effect = RuntimeError("authorization unavailable") + + with pytest.raises(RuntimeError, match="authorization unavailable"): + await info_case.service.get_space_info(303) + + info_case.member_count.assert_not_awaited() + assert info_case.metric.call_args.kwargs["failed_stage"] == "auth" + + +@pytest.mark.parametrize("current_tenant_id,should_rebuild", [(1, True), (2, False)]) +async def test_ac05_rebuild_dispatch_keeps_tenant_guard(info_case, current_tenant_id, should_rebuild): + info_case.space.state = KnowledgeState.REBUILDING.value + info_case.space.tenant_id = 1 + with patch(f"{MODULE}.get_current_tenant_id", return_value=current_tenant_id): + await info_case.service.get_space_info(303) + + if should_rebuild: + info_case.rebuild.assert_called_once_with( + 303, + new_model_id=info_case.space.model, + invoke_user_id=info_case.service.login_user.user_id, + ) + else: + info_case.rebuild.assert_not_called() diff --git a/src/backend/test/knowledge/test_knowledge_space_service.py b/src/backend/test/knowledge/test_knowledge_space_service.py index 30631bf66d..096f1a82bc 100644 --- a/src/backend/test/knowledge/test_knowledge_space_service.py +++ b/src/backend/test/knowledge/test_knowledge_space_service.py @@ -473,16 +473,25 @@ async def test_joined_visibility_does_not_expand_super_admin_identity( @pytest.mark.parametrize( "status", - [ - SpaceSubscriptionStatusEnum.SUBSCRIBED, - SpaceSubscriptionStatusEnum.PENDING, - SpaceSubscriptionStatusEnum.REJECTED, - ], + list(SpaceSubscriptionStatusEnum), ) -def test_visible_permission_preserves_non_default_subscription_status( +def test_visible_permission_takes_precedence_over_application_status( status: SpaceSubscriptionStatusEnum, ) -> None: - assert KnowledgeSpaceService._resolve_effective_subscription_status(status, has_visible=True) == status + assert ( + KnowledgeSpaceService._resolve_effective_subscription_status(status, has_visible=True) + == SpaceSubscriptionStatusEnum.SUBSCRIBED + ) + + +@pytest.mark.parametrize("status", list(SpaceSubscriptionStatusEnum)) +def test_without_visible_permission_preserves_application_state_but_clears_stale_joined( + status: SpaceSubscriptionStatusEnum, +) -> None: + expected = ( + SpaceSubscriptionStatusEnum.NOT_SUBSCRIBED if status == SpaceSubscriptionStatusEnum.SUBSCRIBED else status + ) + assert KnowledgeSpaceService._resolve_effective_subscription_status(status, has_visible=False) == expected @pytest.mark.parametrize("is_released", [False, True]) diff --git a/src/backend/test/knowledge/test_square_revoked_membership.py b/src/backend/test/knowledge/test_square_revoked_membership.py index fcbd2c891b..2d2ad31d77 100644 --- a/src/backend/test/knowledge/test_square_revoked_membership.py +++ b/src/backend/test/knowledge/test_square_revoked_membership.py @@ -7,11 +7,13 @@ import ast import asyncio import sys +from contextlib import contextmanager from datetime import datetime from enum import StrEnum from pathlib import Path +from time import perf_counter from types import ModuleType, SimpleNamespace -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, Mock import pytest from sqlmodel import Field, SQLModel @@ -99,6 +101,9 @@ def context(monkeypatch): "KnowledgeUtils": object, "datetime": datetime, "asyncio": asyncio, + "perf_counter": perf_counter, + "contextmanager": contextmanager, + "emit_metric": Mock(), "SpaceSubscriptionStatusEnum": Status, "MembershipStatusEnum": Membership, "UserRoleEnum": Role, @@ -138,6 +143,9 @@ def context(monkeypatch): service.login_user = SimpleNamespace(user_id=7, user_name="viewer", tenant_id=1) service.request = None service.message_service = None + service._permission_actor_resolve_count = 0 + service._permission_actor_cache_hits = 0 + service._permission_actor_resolve_elapsed_ms = 0.0 service._require_space_info_permission = AsyncMock(return_value=(space, False)) service._has_joined_visibility = AsyncMock(return_value=False) service._get_effective_actions = AsyncMock(return_value=set()) @@ -165,8 +173,9 @@ def test_joined_status_uses_current_visibility(context, status, visible): @pytest.mark.parametrize("status", [Status.PENDING, Status.REJECTED]) @pytest.mark.parametrize("visible", [False, True]) -def test_application_states_keep_their_existing_contract(context, status, visible): - assert context.service._resolve_effective_subscription_status(status, has_visible=visible) == status +def test_application_states_are_only_shown_without_current_visibility(context, status, visible): + expected = Status.SUBSCRIBED if visible else status + assert context.service._resolve_effective_subscription_status(status, has_visible=visible) == expected @pytest.mark.parametrize("role", [Role.MEMBER, Role.ADMIN]) From ff2b89aa077d1f155542433f8e6b81c5d75b6923 Mon Sep 17 00:00:00 2001 From: GuoQing Zhang Date: Mon, 2 Nov 2026 17:17:41 +0800 Subject: [PATCH 7/8] fix(permission): retain existing grants for inactive models --- .../design.md | 21 +++- .../tasks.md | 25 +++++ ...ation-inactive-existing-grants-20261102.md | 91 +++++++++++++++ .../permission/application/control_state.py | 3 +- .../permission/application/resource_api.py | 10 +- .../bisheng/permission/application/runtime.py | 36 +++++- .../domain/services/grant_service.py | 35 +++++- .../services/permission_explain_service.py | 4 +- .../permission/test_f048_catalog_runtime.py | 8 +- .../test_f048_permission_explain_service.py | 27 ++++- .../test/permission/test_f048_resource_api.py | 93 +++++++++++++++- .../permission/test_f115_action_sources.py | 9 ++ .../test_inactive_model_existing_roster.py | 105 ++++++++++++++++++ .../permission/PermissionDraftEditor.tsx | 6 +- .../permission/PermissionGrantTab.test.tsx | 29 ++++- .../permission/PermissionGrantTab.tsx | 2 + .../permission/PermissionListTab.tsx | 2 + .../permission/PermissionModelStatus.tsx | 15 +++ .../components/permission/assigneePolicy.ts | 2 +- .../permission/permissionDraftPolicy.test.ts | 9 ++ .../permission/permissionDraftPolicy.ts | 2 +- .../permission/usePermissionDraft.ts | 1 + .../client/src/locales/en/translation.json | 1 + .../client/src/locales/ja/translation.json | 1 + .../src/locales/zh-Hans/translation.json | 1 + .../ChannelSettings/useChannelSettingsForm.ts | 1 + .../useKnowledgeSpaceSettingsForm.ts | 1 + .../public/locales/en-US/permission.json | 1 + .../public/locales/ja/permission.json | 1 + .../public/locales/zh-Hans/permission.json | 1 + .../bs-comp/permission/PermissionGrantTab.tsx | 2 + .../bs-comp/permission/PermissionListTab.tsx | 2 + .../permission/PermissionModelStatus.tsx | 15 +++ .../bs-comp/permission/assigneePolicy.ts | 2 +- .../src/test/f048PermissionGrantTab.test.tsx | 27 ++++- 35 files changed, 566 insertions(+), 25 deletions(-) create mode 100644 features/v3.0.0-beta1/048-rebac-permission-model-grants/verification-inactive-existing-grants-20261102.md create mode 100644 src/backend/test/permission/test_inactive_model_existing_roster.py create mode 100644 src/frontend/client/src/components/permission/PermissionModelStatus.tsx create mode 100644 src/frontend/platform/src/components/bs-comp/permission/PermissionModelStatus.tsx diff --git a/features/v3.0.0-beta1/048-rebac-permission-model-grants/design.md b/features/v3.0.0-beta1/048-rebac-permission-model-grants/design.md index 8ebf13820d..627c256e28 100644 --- a/features/v3.0.0-beta1/048-rebac-permission-model-grants/design.md +++ b/features/v3.0.0-beta1/048-rebac-permission-model-grants/design.md @@ -10,7 +10,7 @@ **关联**:[spec.md](./spec.md) · [产品说明](./product-authorization-model-and-migration-guide.md) · [release contract](../release-contract.md) **版本**:v3.0.0-beta1 **功能分支**:`feat/v3.0.0-beta1/048-rebac-permission-model-grants` -**最后更新**:2026-08-13 +**最后更新**:2026-11-02 --- @@ -1135,7 +1135,7 @@ scope/version/operation 不匹配继续转人工分析。重复传入 `FINALIZED | `permission/domain/services/catalog_service.py` | draft、影响分析、标准模型生成、Catalog publish 编排 | 不直接 ORM/FGA HTTP | | `.../projection_service.py` | operation、tuple phase、幂等提交、reconcile、recent marker | 不决定谁可授权 | | `.../visibility_projection_service.py` | 从 canonical Grant assignee 编译单槽浅层 visible contribution、跨来源引用计数、checksum/reconcile;迁移器与运行时复用同一纯编译器 | 不展开部门/用户组成员;system/public/shared 仍由各 Owner projection 处理;不接受业务编辑;不参与 Check/List fallback | -| `.../grant_service.py` | 来源、模型、版本、protected 和同级规则编排 | 不读取 Config | +| `.../grant_service.py` | 来源、模型、版本、protected 和同级规则编排;新增目标要求 active,既有来源管理复用相同等级规则但不要求模型 active | 不读取 Config | | `.../mode_service.py` | 已验证 parent/mode context 的 impact/snapshot/projection | 不查询或移动业务资源,不维护第二 parent | | `.../permission_service.py` | 唯一权限 facade:具体 action check/batch-check、visible check/batch-check/完整流式枚举、Grant 与 projection | 不查询业务资源,不判断状态/父级,不 fallback ALLOW;个人 visible 不做管理员扩权短路 | | `.../permission_explain_service.py` | 已经 FGA 判定后的 opaque subject/source/model ID、scope 与 protected 说明 | 不产生 ALLOW,不查询用户/部门/用户组/资源名称 | @@ -1210,6 +1210,7 @@ domain 对业务 model/repository 的 import 必须由架构测试禁止。正 | 32 | 文件夹本级授权不意味着整个知识空间可见;读取指定文件夹时只要求该容器可见,子项仍逐项执行包含继承的最终判断 | 先校验空间 visible 会使只有某层文件夹权限的账号无法列出或搜索其下级;直接放行全部子项又会越过 CUSTOM 边界 | `KnowledgeSpaceService._require_container_read_permission`,由普通列表与搜索共用;根目录仍校验空间 visible | | 33 | 服务账号继承需要每层资源的 `service_account:*` mode/permission_enabled 技术标记;这些标记不授予任何账号资源权限 | 旧数据只有 `user:*` 时会出现空间 visible 成功而子项不可见、上传失败;新增 Grant 不会补齐存量后代的技术标记 | 创建与迁移编译器写入两类主体标记;模型发布脚本 `publish_authorization_model_change.py` 和完整对账脚本 `reconcile_f048_visible_projection.py` 在维护窗口按 CURRENT 模式补齐,保留 CUSTOM 边界;模型已一致也必须补齐并校验后才返回成功 | | 34 | 预置工具的历史 `user_id` 可以非空;预置身份只由 `GptsToolsType.is_preset=1` 决定;权限 mirror 为 `CURRENT` 也可能缺失系统 tuples | 把创建者为空作为额外条件会从工作台剔除预置工具,并错误地按 USER 迁移;只修分类或只检查 mirror 不能补齐存量系统授权 | 运行时、迁移、清单与租户复制统一判定;显式系统资源审计读取真实 wildcard `visible/system_use_marker`,仅为有效预置工具经版本围栏和 durable ledger 补缺,保留既有 Grant,拒绝 non-CURRENT;预置工具仍只支持 `visible/use` 并经实际动作鉴权 | +| 35 | 模型 inactive 只禁止新分配,既有 ACTIVE Grant/assignee 仍可提供 visible、具体动作和管理能力 | 名单、我的权限或审批当前授权摘要按 model.active 过滤,会出现“实际可访问但授权页无来源”;复用可分配模型列表管理旧绑定又会无法撤销 | `load_source_page`、`PermissionExplainService` 和 `read_effective_action_sources` 保留既有来源;名单返回真实 model.active;服务端按相同等级策略计算 editable,两套前端区分旧来源管理与新目标选择 | --- @@ -1243,6 +1244,13 @@ Grant 列表使用 `PageInfiniteCursorData`,不返回需要全量权限扫描 请求参数为 `cursor`(opaque、可空)与 `page_size`(默认 50、最大 100);cursor 绑定 tenant/resource/current Catalog/resource version,任一变化返回 25002,不能跨资源复用。 +名单与搜索保留已停用模型的既有有效来源,`model.active` 返回 Catalog 中真实的可分配状态。 +`editable` 同时满足本级 CUSTOM、非 protected 和服务端 manage_permission/等级/同级策略; +停用模型不会因此成为新目标。`grants:mutate` 响应同样保留未撤销的停用模型来源,不能在 +一次无关操作后让它从表单消失。两套 SPA 显示停用提示;Client 知识空间及频道设置表单 +保留 `modelActive` 元数据,允许依法精确撤销或移动到可分配模型。新增、MOVE 目标和复制 +仍沿用 active 校验,原版本围栏、租户边界、继承和受保护来源规则保持生效。 + #### 具体 action check ```json @@ -1390,6 +1398,13 @@ Grant/assignee 引用均为零,并要求 source projection/live tuple 残留 ### 6.2 内部 Python API +`F048PermissionRuntime.manageable_model_keys(actor, target)` 供权限 API 序列化既有来源的 +管理资格使用:先执行 manage_permission 与 CURRENT target 校验,再对有界 Catalog 模型 +应用 `GrantService.manageable_models_for_capabilities`。它与新分配的等级/同级策略共用判断, +只取消来源模型的 active 要求;不加载完整名单、不代替写入时的最终校验。分页读取复用 +同一策略细化每条来源的 editable;审批的内部 action 来源摘要仍只在原阅读门禁后调用, +不得把 SQL 来源说明当作 OpenFGA 授权结果。 + | 契约 | 调用方 | 说明 | |---|---|---| | `PermissionService.check_action(target, action, actor)` | 已加载资源的业务 Service | 唯一具体动作入口;target 必须是服务端 verified DTO | @@ -1980,6 +1995,8 @@ D3 已完成全部旧运行数据退役,D6 没有延后的 cleanup 窗口。 ## 修订历史 +- 2026-11-02:修正停用模型既有授权在名单、搜索、我的权限及审批当前授权摘要中的遗漏;真实返回 model.active,按原等级规则开放旧来源管理并保留新目标 active 校验。依据空间 3254 的只读排查与用户确认方案,不迁移或撤销存量授权。验证见 [本次记录](./verification-inactive-existing-grants-20261102.md)。 + - 2026-10-29:修复租户管理员构建列表的跨租户快捷放行;权限冲突码迁至 25020–25026,保留信息源原码及存量资源归属;仅执行本缺陷定向验证。 | 日期 | 改动 | 触发原因 | diff --git a/features/v3.0.0-beta1/048-rebac-permission-model-grants/tasks.md b/features/v3.0.0-beta1/048-rebac-permission-model-grants/tasks.md index d990fac780..c0c9086c7e 100644 --- a/features/v3.0.0-beta1/048-rebac-permission-model-grants/tasks.md +++ b/features/v3.0.0-beta1/048-rebac-permission-model-grants/tasks.md @@ -1683,6 +1683,31 @@ - **验收**:T197、F048 permission focused suite、Ruff、arch-guard 与 diff-check 通过。 - **依赖**:T197 +## Wave 17 — 停用模型既有授权展示纠正(2026-11-02) + +本轮落实已确认 AC-15/164/166 与用户认可的修复方案,不重新定义授权规则。 + +- [x] **T199:既有停用授权的后端定向合同验证** + - **文件**:`test/permission/test_inactive_model_existing_roster.py`,以及 F048 explain、catalog runtime、resource API、grant policy/source 与 F115 action sources 的定向测试。 + - **覆盖 AC**:AC-15, AC-164, AC-166。 + - **验收**:55 项通过;覆盖直接/继承来源、分页/搜索、同级和等级限制、protected、权限拒绝、Catalog 读取冲突及 mutation 响应。 + +- [x] **T200:修复后端来源读取及既有来源管理资格** + - **文件**:`permission/application/{control_state,resource_api,runtime}.py`,`permission/domain/services/{grant_service,permission_explain_service}.py`。 + - **逻辑**:保留有效既有来源,返回真实 model.active,服务端细化 editable;不放开新目标 active 校验。 + - **依赖**:T199。 + +- [x] **T201:两套 SPA 展示状态与表单管理修复** + - **文件**:两套 `PermissionGrantTab` / `PermissionListTab` / `assigneePolicy` / `PermissionModelStatus`,Client `PermissionDraftEditor` / draft policy / row type、知识空间和频道表单映射,三语文案及定向测试。 + - **覆盖 AC**:AC-15, AC-164, AC-166。 + - **验收**:Platform 9 项、Client 临时 runner 25 项通过;lint 与 i18n 通过。TypeScript 基线限制与 runner 差异见验证记录。 + - **依赖**:T200。 + +- [ ] **T202:部署后定向 E2E** + - **范围**:3254 只读核对来源;专用测试资源验证停用、拒绝新分配、精确撤销/移动、多来源并集与非管理员权限拒绝。 + - **状态**:本轮未部署,真实环境 E2E 未执行;不在空间 3254 上撤权或修改模型。 + - **依赖**:T200, T201 与目标环境更新。 + ## 实际偏差记录 > 只记录一句话指针;设计原因和反直觉事实回写 [design.md](./design.md)。 diff --git a/features/v3.0.0-beta1/048-rebac-permission-model-grants/verification-inactive-existing-grants-20261102.md b/features/v3.0.0-beta1/048-rebac-permission-model-grants/verification-inactive-existing-grants-20261102.md new file mode 100644 index 0000000000..4c22f2dfcd --- /dev/null +++ b/features/v3.0.0-beta1/048-rebac-permission-model-grants/verification-inactive-existing-grants-20261102.md @@ -0,0 +1,91 @@ +# 停用模型既有授权:修复与验证记录 + +日期:2026-11-02。分支:`codex/permission-inactive-grant-20261102`。 +基线:最新远端 `feat/3.0.0` 的 `368894d9a`;主工作树及其他未提交内容未改动。 + +## 1. 已确认规则与问题 + +本次实现用户认可的方案,纠正 F048 AC-15、AC-164、AC-166 的实现偏差: + +- 停用权限模型只阻止新增绑定、移动到该模型及复制新的该模型绑定;既有有效授权继续生效。 +- 名单、搜索、我的权限说明与审批当前授权摘要应保留这些真实来源。 +- 原管理权限、等级/同级策略、protected、继承、租户边界和乐观锁继续约束撤销及替换。 + +116 只读排查发现:空间 3254 的部门 310 来源仍有效,引用已停用自定义模型“1111”; +wangxinlei1 属于该部门,因此在“我加入的”中可见符合既有规则。问题是权限说明按模型 +active 过滤,导致管理页面漏掉这项授权。此处保留排查结论,未在本轮重新写入数据库或 FGA。 + +## 2. 实现 + +| 层次 | 修改 | +|---|---| +| SQL 来源读取 | `load_source_page` 保留已知模型的既有来源;仍限定同租户、ACTIVE/CURRENT Grant、ACTIVE assignee 和合法继承路径;分页有界 | +| 权限说明 | `PermissionExplainService` 保留停用模型的直接/继承来源和动作;inactive Grant / assignee 仍排除 | +| 审批摘要 | `read_effective_action_sources` 继续按具体 action、CURRENT 状态、读围栏和容量限制返回来源,不按模型可分配状态删除来源 | +| 既有来源管理 | 与新分配共用等级/同级判断,仅去掉旧来源模型 active 要求;服务端以此细化 editable,无完整名单扫描 | +| API | 名单返回真实 model.active;若来源模型不属于读取的 Catalog 快照则明确返回版本冲突;mutation 响应保留其他未撤销的停用模型来源 | +| 两套 SPA | 显示“该权限已停用,已有授权仍有效。”;管理旧来源与选择新目标分开,保留服务端只读限制 | +| Client 设置表单 | 知识空间、频道 hydrate 保留 modelActive;移动到可分配目标后更新该元数据 | + +无数据库结构变更、新错误码、新依赖或 OpenFGA 模型变更。没有迁移、启用或撤销 116 的存量授权。 + +## 3. 定向验证 + +| 检查 | 结果 | 范围/限制 | +|---|---|---| +| Backend pytest | **55 passed** | `test_inactive_model_existing_roster.py`、F048 explain/resource API/grant policy/grant sources、F115 action sources,以及 catalog runtime 中有界分页和最近 CUSTOM 祖先的两个测试(含 active 参数);覆盖读取时 Catalog 模型缺失的冲突响应 | +| Platform Vitest | **9 passed** | `src/test/f048PermissionGrantTab.test.tsx`;包含停用来源精确 REMOVE、只读行拒绝、新目标排除及已有版本冲突路径 | +| Client 组件/纯函数 | **25 passed** | `PermissionGrantTab`、`PermissionListTab`、`permissionDraftPolicy`,在临时 Vitest 适配器执行原断言 | +| 前端 lint | **PASS** | 从 `src/frontend` 执行 `pnpm lint`,覆盖两个 SPA 及共享包 | +| 三语 key / i18n | **PASS** | `node scripts/check-i18n.mjs`;无新增漂移,既有 84 项 frontend-only 基线保留 | +| TypeScript | **基线阻塞,无新增诊断** | Platform 23 项、Client 135 项;在 `368894d9a` 临时只读源码副本、同一组已安装依赖下逐条比较一致(忽略绝对路径与新增 import 引起的行号偏移) | +| Ruff / arch-guard / diff-check | **PASS** | 本次改动范围;不执行全量测试 | +| 真实部署 E2E | **未执行** | 本轮未更新 116;见下方验收清单,T202 保持未完成 | + +### 本地 runner 限制 + +Client 原 Jest 在测试初始化时报缺少 `canvas.node`;node 环境纯函数用例还会被全局 setup +的 Element 引用阻断。未安装依赖或修改仓库 runner。临时 Vitest 适配只转换 jest/vi API、 +同步 locale 读取及导入路径,并对齐 clearMocks/restoreMocks;使用原 Button、共享 UI 和 +原插画组件,绕过未使用的 barrel 导出,权限逻辑和断言不变。临时文件均在 `/private/tmp`。 + +额外运行的 Platform `f048PermissionDialog` 有一个旧用例把 subject Tab 查询成 button; +在未修改基线 `368894d9a` 上同样复现。未改业务 Tab 语义或扩大本次修复范围。 + +架构守卫以仓库相对路径检查全部改动通过。传入绝对工作树路径时,RULE-9 会将路径中的 +`/bisheng/` 仓库名误当应用包前缀,从而对 permission 测试中原有的 OpenFGA import 报警; +已核对其 import 与基线完全相同,未修改守卫或添加例外。 + +## 4. 任务与静态复审 + +按 `task-review` 检查 T199–T201:分层、命名、序列化、数据库、前端约定、信息泄漏及设计 +同步通过;无新增 ORM/原生 SQL、凭证、菜单或鉴权短路。新增文案三语同时提供,两个 SPA +组件分别实现;修改的前端文件均不超过 600 行。 + +本缺陷按 `sdd-code-review` 七维度由同一代理复审: + +| 维度 | 结论 | +|---|---| +| 边界 | 空页/搜索/直接与继承、停用模型、inactive Grant/assignee 均有定向验证;缺失模型不凭空恢复 | +| 权限与认证 | 普通主体测试管理等级及拒绝路径;原 OpenFGA 鉴权、租户/目标及新目标 active 限制保留 | +| 并发 | 未更改写协议;资源/Catalog/assignee 版本、幂等和投影账本继续由原写服务校验 | +| 信息泄漏 | 没有加入环境凭证、内部地址或跨租户数据;说明 API 仍位于原阅读门禁之后 | +| 测试 | 本地定向验证通过;TypeScript/旧 Dialog 用例有已复现的基线限制,真实部署证据待补 | +| 风格 | Ruff、lint、架构守卫通过;无新增忽略/抑制或依赖 | +| 文档 | F048 design 的职责、名单字段、已知坑、内部 API 与修订历史已同步 | + +代码静态复审完成;不以此宣称整个 F048、CI 或真实环境 E2E 全部通过。 + +## 5. 部署后最小验收(待执行) + +1. 对空间 3254 只读核对授权名单/搜索:可见部门 310、“1111”、active=false 及停用提示; + wangxinlei1 仍可在“我加入的”看到空间,普通使用/下载动作保持原结果,设置权限不额外放开。 +2. 专用 `e2e-inactive-grant-` 测试资源:创建模型和部门/用户组绑定后停用模型; + 管理者仍能读取来源,普通成员可见性及具体动作保持;无管理权限者拒绝读完整名单或写入。 +3. 新增/移动/复制到已停用模型均拒绝;高等级、protected、继承来源或过期版本仍拒绝修改。 +4. 在专用资源精确撤销或移动旧来源;保留同一用户的另一有效来源,确认其可见性仍存在; + 最后一项来源撤销后才失去对应访问。 +5. 仅清理本次创建的测试对象;空间 3254、Grant 15396 及存量模型不作变更。 + +交付目标:用户于 2026-11-02 要求将本修复提交并推送到 `feat/3.0.0`。 +本记录覆盖代码及本地定向验证;真实环境 E2E 待部署后补充,不包含环境更新。交付提交号以 Git 历史为准。 diff --git a/src/backend/bisheng/permission/application/control_state.py b/src/backend/bisheng/permission/application/control_state.py index 677585febf..8d5c3321f4 100644 --- a/src/backend/bisheng/permission/application/control_state.py +++ b/src/backend/bisheng/permission/application/control_state.py @@ -512,7 +512,8 @@ async def load_source_page( if after_id < 0 or limit <= 0: raise ValueError("Permission source cursor bounds are invalid") normalized_mode = mode.upper() - model_by_key = {model.model_key: model for model in models if model.active} + # Model deactivation only prevents new assignments; existing sources remain effective. + model_by_key = {model.model_key: model for model in models} if not model_by_key: return (), False fetch_limit = limit + 1 diff --git a/src/backend/bisheng/permission/application/resource_api.py b/src/backend/bisheng/permission/application/resource_api.py index 3ba4a852c1..8705868cfb 100644 --- a/src/backend/bisheng/permission/application/resource_api.py +++ b/src/backend/bisheng/permission/application/resource_api.py @@ -485,6 +485,9 @@ async def list_grants( ) parent_names = await self._subjects.resource_display_names(parents) if parents else {} model_names = {item.snapshot.model_key: item.name for item in catalog.models} + model_states = {item.snapshot.model_key: item.snapshot.active for item in catalog.models} + if any(row.model_key not in model_states for row in selected): + raise PermissionVersionConflictError(msg="Permission Catalog changed while reading sources") data = [ { "assignee_id": str(row.source_id), @@ -501,7 +504,7 @@ async def list_grants( row.model_key, ), "level": row.model_level, - "active": True, + "active": model_states[row.model_key], }, "source": { "type": row.source_type, @@ -651,6 +654,7 @@ async def mutate_grants( target_model_key=change.target_model_key, ) ) + manageable = await self._runtime.manageable_model_keys(actor=actor, target=target) result = await self._runtime.mutate_grants( actor=actor, target=target, @@ -681,10 +685,10 @@ async def mutate_grants( "scope": "LOCAL", "inherited_from": None, "protected": source.protected, - "editable": not source.protected, + "editable": not source.protected and grant.model.model_key in manageable, } for grant in result.grants - if grant.active and grant.model.active + if grant.active for source in grant.sources if source.active ] diff --git a/src/backend/bisheng/permission/application/runtime.py b/src/backend/bisheng/permission/application/runtime.py index 9032fbed06..5bee6e363d 100644 --- a/src/backend/bisheng/permission/application/runtime.py +++ b/src/backend/bisheng/permission/application/runtime.py @@ -896,6 +896,34 @@ async def grantable_models( ) -> tuple[GrantModelSnapshot, ...]: return self._grants.grantable_models(await self.build_grant_context(actor=actor, target=target)) + async def manageable_model_keys( + self, + *, + actor: PermissionActor, + target: VerifiedPermissionTarget, + ) -> frozenset[str]: + """Read existing-binding eligibility without loading the complete roster.""" + + await self._require_manage_permission(actor, target) + await self._require_current_target(target) + catalog = await self._runtime_catalog() + return await self._manageable_model_keys(actor, target, catalog) + + async def _manageable_model_keys( + self, + actor: PermissionActor, + target: VerifiedPermissionTarget, + catalog: RuntimeCatalogSnapshot, + ) -> frozenset[str]: + system_authorized = self._system_authorized(actor, target) + capabilities = () if system_authorized else await self._grant_capabilities(actor, target) + models = self._grants.manageable_models_for_capabilities( + models=tuple(item.snapshot for item in catalog.models), + capabilities=capabilities, + system_authorized=system_authorized, + ) + return frozenset(model.model_key for model in models) + async def mutate_grants( self, *, @@ -1304,13 +1332,17 @@ async def list_permission_sources_page( await self._require_manage_permission(actor, target) catalog = await self._runtime_catalog() mode = await self._require_current_target(target) - return await self._state.load_source_page( + rows, has_more = await self._state.load_source_page( target=target, mode=mode.mode, models=tuple(item.snapshot for item in catalog.models), after_id=after_id, limit=limit, ) + if not any(row.editable for row in rows): + return rows, has_more + manageable = await self._manageable_model_keys(actor, target, catalog) + return tuple(replace(row, editable=row.editable and row.model_key in manageable) for row in rows), has_more async def read_effective_action_sources(self, *, target, action): """Trusted business read after its own qualification; not an HTTP roster. @@ -1322,7 +1354,7 @@ async def read_effective_action_sources(self, *, target, action): models = tuple(item.snapshot for item in catalog.models) before = await self._state.source_read_stamp(target=target, models=models) mode = await self._require_current_target(target) - eligible = tuple(model for model in models if model.active and action in model.action_codes) + eligible = tuple(model for model in models if action in model.action_codes) rows, after_id = [], 0 while eligible: page, more = await self._state.load_source_page( diff --git a/src/backend/bisheng/permission/domain/services/grant_service.py b/src/backend/bisheng/permission/domain/services/grant_service.py index 9bc508f711..4e01ff50fb 100644 --- a/src/backend/bisheng/permission/domain/services/grant_service.py +++ b/src/backend/bisheng/permission/domain/services/grant_service.py @@ -168,8 +168,41 @@ def grantable_models_for_capabilities( ) -> tuple[GrantModelSnapshot, ...]: """Apply the Grant level policy without requiring a resource target.""" + return self._models_for_capabilities( + models=models, + capabilities=capabilities, + system_authorized=system_authorized, + require_active=True, + ) + + def manageable_models_for_capabilities( + self, + *, + models: tuple[GrantModelSnapshot, ...], + capabilities: tuple[GrantCapability, ...], + system_authorized: bool = False, + ) -> tuple[GrantModelSnapshot, ...]: + """Apply the same level policy to existing, possibly inactive bindings.""" + + return self._models_for_capabilities( + models=models, + capabilities=capabilities, + system_authorized=system_authorized, + require_active=False, + ) + + def _models_for_capabilities( + self, + *, + models: tuple[GrantModelSnapshot, ...], + capabilities: tuple[GrantCapability, ...], + system_authorized: bool, + require_active: bool, + ) -> tuple[GrantModelSnapshot, ...]: available = tuple( - model for model in models if model.active and model.derived_level is not None and model.action_codes + model + for model in models + if (model.active or not require_active) and model.derived_level is not None and model.action_codes ) if system_authorized: return tuple( diff --git a/src/backend/bisheng/permission/domain/services/permission_explain_service.py b/src/backend/bisheng/permission/domain/services/permission_explain_service.py index 7579be61c0..6bb0eb0d8e 100644 --- a/src/backend/bisheng/permission/domain/services/permission_explain_service.py +++ b/src/backend/bisheng/permission/domain/services/permission_explain_service.py @@ -116,7 +116,7 @@ def _effective_sources( rows: list[_EffectiveSource] = [] mode = context.mode.upper() for grant in context.local_grants: - if not grant.active or not grant.model.active: + if not grant.active: continue for source in grant.sources: if not source.active: @@ -136,7 +136,7 @@ def _effective_sources( if mode == "INHERIT" and context.inherited is not None: inherited_from = f"{context.inherited.resource_type}:{context.inherited.resource_id}" for grant in context.inherited.grants: - if not grant.active or not grant.model.active: + if not grant.active: continue rows.extend( self._row( diff --git a/src/backend/test/permission/test_f048_catalog_runtime.py b/src/backend/test/permission/test_f048_catalog_runtime.py index b323c259f0..d66082e9ee 100644 --- a/src/backend/test/permission/test_f048_catalog_runtime.py +++ b/src/backend/test/permission/test_f048_catalog_runtime.py @@ -762,9 +762,11 @@ async def test_catalog_create_is_idempotent_and_current_shape_is_complete( assert count == 1 +@pytest.mark.parametrize("model_active", [True, False]) async def test_permission_roster_sql_cursor_reads_only_one_bounded_page( session_factory: SessionFactory, monkeypatch, + model_active, ) -> None: source_service = GrantSourceService() sources = [ @@ -826,7 +828,7 @@ async def test_permission_roster_sql_cursor_reads_only_one_bounded_page( models = ( GrantModelSnapshot( model_key="viewer", - active=True, + active=model_active, action_codes=("visible",), derived_level=1, ), @@ -854,9 +856,11 @@ async def test_permission_roster_sql_cursor_reads_only_one_bounded_page( assert second_has_more is False +@pytest.mark.parametrize("model_active", [True, False]) async def test_inherited_roster_uses_nearest_custom_ancestor( session_factory: SessionFactory, monkeypatch, + model_active, ) -> None: source_service = GrantSourceService() root_source = source_service.canonicalize_source( @@ -964,7 +968,7 @@ async def test_inherited_roster_uses_nearest_custom_ancestor( models = ( GrantModelSnapshot( model_key="viewer", - active=True, + active=model_active, action_codes=("visible",), derived_level=1, ), diff --git a/src/backend/test/permission/test_f048_permission_explain_service.py b/src/backend/test/permission/test_f048_permission_explain_service.py index de36ce20d1..ba5b37fb47 100644 --- a/src/backend/test/permission/test_f048_permission_explain_service.py +++ b/src/backend/test/permission/test_f048_permission_explain_service.py @@ -213,7 +213,8 @@ async def test_without_roster_permission_only_actor_sources_are_returned() -> No @pytest.mark.asyncio -async def test_inactive_model_or_grant_never_appears_in_explanation() -> None: +@pytest.mark.parametrize("inherited", [False, True]) +async def test_inactive_model_keeps_existing_explanation(inherited) -> None: sources = _sources() source = sources.canonicalize_source( source_id=1, @@ -229,8 +230,28 @@ async def test_inactive_model_or_grant_never_appears_in_explanation() -> None: grant, model=replace(grant.model, active=False), ) - inactive_grant = replace(grant, active=False) - for candidate in (inactive_model, inactive_grant): + explanation = await PermissionExplainService().explain( + PermissionExplainContext( + tenant_id=7, + resource_type="folder", + resource_id="42", + resource_version=3, + mode="INHERIT" if inherited else "CUSTOM", + parent_type="knowledge_space", + parent_id="10", + local_grants=() if inherited else (inactive_model,), + inherited=InheritedGrantSet("knowledge_space", "10", (inactive_model,)) if inherited else None, + actor_projected_subjects=frozenset({"user:100"}), + can_manage_roster=True, + ) + ) + assert [row.source_id for row in explanation.sources] == [1] + assert explanation.action_codes == ("edit",) + assert explanation.sources[0].editable is not inherited + for candidate in ( + replace(inactive_model, active=False), + replace(inactive_model, sources=(replace(source, active=False),)), + ): explanation = await PermissionExplainService().explain( PermissionExplainContext( tenant_id=7, diff --git a/src/backend/test/permission/test_f048_resource_api.py b/src/backend/test/permission/test_f048_resource_api.py index d93c23987c..1660a480ea 100644 --- a/src/backend/test/permission/test_f048_resource_api.py +++ b/src/backend/test/permission/test_f048_resource_api.py @@ -6,6 +6,7 @@ import pytest +from bisheng.common.errcode.permission import PermissionVersionConflictError from bisheng.permission.application.resource_api import ( F048ResourcePermissionApi, ) @@ -74,6 +75,7 @@ class _Runtime: def __init__(self) -> None: self.changes = () self.page_calls = [] + self.model_active = True async def allocate_source_ids(self, count): assert count == 1 @@ -84,7 +86,7 @@ async def mutate_grants(self, **kwargs): model = SimpleNamespace( model_key="viewer", derived_level=1, - active=True, + active=self.model_active, ) source = SimpleNamespace( source_id=91, @@ -110,9 +112,12 @@ async def mutate_grants(self, **kwargs): async def require_manage_permission(self, actor, target): del actor, target + async def manageable_model_keys(self, **kwargs): + return frozenset({"viewer"}) + async def current_catalog(self): model = SimpleNamespace( - snapshot=SimpleNamespace(model_key="viewer"), + snapshot=SimpleNamespace(model_key="viewer", active=self.model_active), name="Viewer", ) return SimpleNamespace(release_id=12, models=(model,)) @@ -531,3 +536,87 @@ async def test_roster_uses_bounded_sql_page_instead_of_full_explanation() -> Non "limit": 25, } ] + + +@pytest.mark.parametrize("keyword", ["", "Reviewers"]) +async def test_inactive_existing_model_is_returned_in_roster_and_search(keyword): + runtime = _Runtime() + runtime.model_active = False + api = F048ResourcePermissionApi(resources=_Resources(), runtime=runtime, subjects=_Subjects()) + result = await api.list_grants( + resource_type="workflow", + resource_id="wf-1", + actor=PermissionActor(user_id=7, current_tenant_id=9), + cursor=None, + page_size=1, + keyword=keyword, + ) + assert result["data"][0]["model"]["active"] is False + assert result["data"][0]["model"]["name"] == "Viewer" + assert result["data"][0]["subject"]["name"] == "Reviewers" + assert result["has_more"] is True + + +async def test_roster_refuses_a_model_missing_from_its_catalog_snapshot(): + class Runtime(_Runtime): + async def current_catalog(self): + catalog = await super().current_catalog() + catalog.models = () + return catalog + + api = F048ResourcePermissionApi(resources=_Resources(), runtime=Runtime(), subjects=_Subjects()) + with pytest.raises(PermissionVersionConflictError): + await api.list_grants( + resource_type="workflow", + resource_id="wf-1", + actor=PermissionActor(user_id=7, current_tenant_id=9), + cursor=None, + page_size=1, + ) + + +async def test_mutation_response_keeps_other_existing_inactive_sources(): + class Runtime(_Runtime): + async def mutate_grants(self, **kwargs): + result = await super().mutate_grants(**kwargs) + result.grants += ( + SimpleNamespace( + model=SimpleNamespace(model_key="old-custom", derived_level=1, active=False), + active=True, + sources=( + SimpleNamespace( + source_id=92, + version=1, + subject_type="department", + subject_id="310", + source_type="DEPARTMENT", + include_children=False, + protected=False, + active=True, + ), + ), + ), + ) + return result + + async def manageable_model_keys(self, **kwargs): + return frozenset({"viewer", "old-custom"}) + + api = F048ResourcePermissionApi(resources=_Resources(), runtime=Runtime(), subjects=_Subjects()) + result = await api.mutate_grants( + resource_type="workflow", + resource_id="wf-1", + actor=PermissionActor(user_id=7, current_tenant_id=9), + request=GrantMutationRequest.model_validate( + { + "idempotency_key": "keep-existing-inactive", + "expected_resource_version": 3, + "expected_catalog_release_id": 12, + "changes": [{"op": "ADD", "model_key": "viewer", "subject": {"type": "user", "id": "8"}}], + } + ), + ) + assert len(result["items"]) == 2 + old = result["items"][1] + assert old["model"]["active"] is False and old["editable"] is True + assert old["subject"] == {"type": "department", "id": "310", "name": None} diff --git a/src/backend/test/permission/test_f115_action_sources.py b/src/backend/test/permission/test_f115_action_sources.py index 19ec428696..1d1f4ae541 100644 --- a/src/backend/test/permission/test_f115_action_sources.py +++ b/src/backend/test/permission/test_f115_action_sources.py @@ -43,6 +43,15 @@ async def test_actions_not_model_names_and_all_pages(): assert service._state.load_source_page.call_args.kwargs["after_id"] == 7 +async def test_inactive_model_still_contributes_existing_action_sources(): + row = SimpleNamespace(source_id=7) + service, target = runtime([((row,), False)]) + catalog = await service._runtime_catalog() + catalog.models[1].snapshot = GrantModelSnapshot("custom", False, ("use",)) + assert await service.read_effective_action_sources(target=target, action="use") == (row,) + assert service._state.load_source_page.call_args.kwargs["models"][0].active is False + + async def test_projection_change_refuses_partial_roster(): from bisheng.common.errcode.permission import PermissionPublishNotReadyError diff --git a/src/backend/test/permission/test_inactive_model_existing_roster.py b/src/backend/test/permission/test_inactive_model_existing_roster.py new file mode 100644 index 0000000000..99a2b86126 --- /dev/null +++ b/src/backend/test/permission/test_inactive_model_existing_roster.py @@ -0,0 +1,105 @@ +"""F048 AC-15/164/166: disabled assignment choices keep existing source semantics.""" + +from dataclasses import replace +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + +from bisheng.common.errcode.permission import GrantLevelForbiddenError +from bisheng.permission.application.runtime import F048PermissionRuntime +from bisheng.permission.domain.schemas import VerifiedPermissionTarget +from bisheng.permission.domain.services.grant_service import GrantCapability, GrantService +from bisheng.permission.domain.services.grant_source_service import GrantModelSnapshot +from bisheng.permission.domain.services.permission_action_service import PermissionActor +from bisheng.permission.domain.services.permission_explain_service import PermissionSourceExplanation + + +def fixture(): + model = GrantModelSnapshot("old-custom", False, ("download", "use"), derived_level=1) + high = GrantModelSnapshot("high-custom", False, ("edit",), derived_level=3) + catalog = SimpleNamespace(models=tuple(SimpleNamespace(snapshot=m) for m in (model, high))) + row = PermissionSourceExplanation( + source_id=11, + source_version=2, + subject_type="department", + subject_id="310", + userset_relation="member", + include_children=False, + source_type="DEPARTMENT", + model_key=model.model_key, + model_level=1, + scope="LOCAL", + inherited_from=None, + protected=False, + editable=True, + ) + target = VerifiedPermissionTarget.from_business_service( + tenant_id=7, + resource_type="knowledge_space", + resource_id="space", + resource_version=3, + context_version="test", + ) + service = object.__new__(F048PermissionRuntime) + service._runtime_catalog = AsyncMock(return_value=catalog) + service._require_manage_permission = AsyncMock() + service._require_current_target = AsyncMock(return_value=SimpleNamespace(mode="CUSTOM")) + service._state = SimpleNamespace(load_source_page=AsyncMock(return_value=((row,), False))) + service._grants = GrantService(source_service=object(), projection=object(), state=object()) + capability = GrantCapability( + model=GrantModelSnapshot("manager", True, ("manage_permission",), derived_level=2), + source_key="manager", + ) + service._grant_capabilities = AsyncMock(return_value=(capability,)) + return service, PermissionActor(user_id=9, current_tenant_id=7), target, row + + +async def test_roster_editability_uses_existing_source_level_not_assignability(): + service, actor, target, row = fixture() + result, more = await service.list_permission_sources_page(actor=actor, target=target, after_id=0, limit=20) + assert more is False and result == (row,) + assert await service.manageable_model_keys(actor=actor, target=target) == frozenset({"old-custom"}) + call = service._state.load_source_page.call_args.kwargs + assert call["limit"] == 20 and call["models"][0].active is False + models = tuple(item.snapshot for item in (await service._runtime_catalog()).models) + capabilities = await service._grant_capabilities(actor, target) + assert service._grants.grantable_models_for_capabilities(models=models, capabilities=capabilities) == () + + +async def test_existing_higher_level_source_remains_read_only(): + service, actor, target, row = fixture() + service._state.load_source_page.return_value = ((replace(row, model_key="high-custom", model_level=3),), False) + rows, _ = await service.list_permission_sources_page(actor=actor, target=target, after_id=0, limit=20) + assert rows[0].editable is False + + +@pytest.mark.parametrize("change", [{"protected": True, "editable": False}, {"scope": "INHERITED", "editable": False}]) +async def test_protected_and_inherited_sources_keep_read_only(change): + service, actor, target, row = fixture() + service._state.load_source_page.return_value = ((replace(row, **change),), False) + rows, _ = await service.list_permission_sources_page(actor=actor, target=target, after_id=0, limit=20) + assert rows[0].editable is False + service._grant_capabilities.assert_not_called() + + +async def test_source_roster_refuses_before_read_when_reader_cannot_manage(): + service, actor, target, _ = fixture() + service._require_manage_permission.side_effect = GrantLevelForbiddenError() + with pytest.raises(GrantLevelForbiddenError): + await service.list_permission_sources_page(actor=actor, target=target, after_id=0, limit=20) + service._state.load_source_page.assert_not_called() + + +def test_same_level_policy_is_shared_by_old_binding_and_new_assignment(): + service, _, _, _ = fixture() + model = GrantModelSnapshot("same-level", False, ("download",), derived_level=2) + capability = GrantCapability( + model=GrantModelSnapshot("manager", True, ("manage_permission",), derived_level=2), + source_key="manager", + ) + policy = service._grants + assert policy.manageable_models_for_capabilities(models=(model,), capabilities=(capability,)) == () + allowed = replace(capability, model=replace(capability.model, allow_same_level=True)) + assert policy.manageable_models_for_capabilities(models=(model,), capabilities=(allowed,)) == (model,) + assert policy.grantable_models_for_capabilities(models=(model,), capabilities=(allowed,)) == () diff --git a/src/frontend/client/src/components/permission/PermissionDraftEditor.tsx b/src/frontend/client/src/components/permission/PermissionDraftEditor.tsx index bc50442130..ed1e261324 100644 --- a/src/frontend/client/src/components/permission/PermissionDraftEditor.tsx +++ b/src/frontend/client/src/components/permission/PermissionDraftEditor.tsx @@ -9,6 +9,7 @@ import { } from "./usePermissionDraft"; import type { PermissionDraftRow } from "./usePermissionDraft"; import { SourceBadge } from "./SourceBadge"; +import { PermissionModelStatus } from "./PermissionModelStatus"; export interface PermissionDraftEditorCapabilities { canChangeRelation: boolean; @@ -43,7 +44,7 @@ export function PermissionDraftEditor({ const rowKey = getPermissionDraftRowKey(row); onChange(value.map((candidate) => ( getPermissionDraftRowKey(candidate) === rowKey - ? { ...candidate, modelKey: model.id, modelName: model.name, modelLevel: model.level } + ? { ...candidate, modelKey: model.id, modelName: model.name, modelLevel: model.level, modelActive: true } : candidate ))); }; @@ -86,7 +87,7 @@ export function PermissionDraftEditor({ const showReadOnly = !row.protected && (row.scope === "INHERITED" || row.editable === false); const showInheritedFrom = row.scope === "INHERITED" && !!row.inheritedFromName; - const hasMeta = !!row.sourceType || showReadOnly || showInheritedFrom; + const hasMeta = !!row.sourceType || showReadOnly || showInheritedFrom || row.modelActive === false; return (
@@ -112,6 +113,7 @@ export function PermissionDraftEditor({ {showReadOnly && ( {localize("f048_permission.roster.read_only")} )} + {showInheritedFrom && ( {localize("f048_permission.roster.inherited_from")}: {row.inheritedFromName} diff --git a/src/frontend/client/src/components/permission/PermissionGrantTab.test.tsx b/src/frontend/client/src/components/permission/PermissionGrantTab.test.tsx index 77bb299742..2cae010632 100644 --- a/src/frontend/client/src/components/permission/PermissionGrantTab.test.tsx +++ b/src/frontend/client/src/components/permission/PermissionGrantTab.test.tsx @@ -306,7 +306,7 @@ describe("F048 Client PermissionGrantTab", () => { ).toBeInTheDocument(); }); - it("keeps an inactive existing row read-only when it is not grantable", async () => { + it("respects server read-only eligibility for an inactive existing row", async () => { render( { context={context} assignees={[ existing("8", { + editable: false, model: { key: "inactive", name: "Inactive", @@ -543,4 +544,30 @@ describe("F048 Client PermissionGrantTab", () => { await screen.findByText("f048_permission.grant.conflict"), ).toBeInTheDocument(); }); + + it("keeps the old model visible and permits precise revocation when authorized", async () => { + const row = existing("8", { + model: { key: "retired", name: "Old permission", level: 1, active: false }, + }); + render( + , + ); + await waitFor(() => + expect(screen.getByLabelText("f048_permission.grant.model.8")).toBeEnabled(), + ); + expect(screen.getByText("f048_permission.roster.inactive_existing_grant")).toBeInTheDocument(); + expect(screen.getByLabelText("f048_permission.grant.add_model")).not.toHaveTextContent("Old permission"); + fireEvent.click(screen.getByLabelText("f048_permission.grant.remove.8")); + fireEvent.click(screen.getByRole("button", { name: "f048_permission.grant.submit" })); + await waitFor(() => expect(mockedMutate).toHaveBeenCalled()); + expect(mockedMutate.mock.calls.at(-1)?.[2].changes).toEqual([ + { op: "REMOVE", assignee_id: "8", expected_assignee_version: 2 }, + ]); + }); }); diff --git a/src/frontend/client/src/components/permission/PermissionGrantTab.tsx b/src/frontend/client/src/components/permission/PermissionGrantTab.tsx index 5e56bad5ad..265208807e 100644 --- a/src/frontend/client/src/components/permission/PermissionGrantTab.tsx +++ b/src/frontend/client/src/components/permission/PermissionGrantTab.tsx @@ -23,6 +23,7 @@ import { Checkbox } from '@bisheng/ui'; import { useLocalize } from "~/hooks"; import { canMutatePermissionAssignee } from "./assigneePolicy"; import { SourceBadge } from "./SourceBadge"; +import { PermissionModelStatus } from "./PermissionModelStatus"; import { SubjectSearchDepartment } from "./SubjectSearchDepartment"; import { SubjectSearchUser } from "./SubjectSearchUser"; import { SubjectSearchUserGroup } from "./SubjectSearchUserGroup"; @@ -432,6 +433,7 @@ export function PermissionGrantTab({

+ {assignee.protected && (
);