fix(agent-assets): allow admin access to platform rules
This commit is contained in:
@@ -0,0 +1,14 @@
|
|||||||
|
# 规则在线表格缺少运行依赖且平台资产下载被拒绝
|
||||||
|
|
||||||
|
日期:2026-07-18
|
||||||
|
文档路径:document/development/2026-07-18/dev-logs/bugs/rule-editor-onlyoffice-runtime-platform-access.md
|
||||||
|
|
||||||
|
## 修复记录
|
||||||
|
- 15:00:记录 bug 修复:规则详情无法加载 ONLYOFFICE,补齐容器后平台规则文件仍因机器会话缺少有效企业而下载失败。
|
||||||
|
- Git 提交检查:`git fetch --all --prune` 成功;upstream `origin/main`;upstream 新提交:未发现;本地 ahead 19 条,最新为 `07241b46 fix(docker): manage local postgres in default compose`、`787bc3a4 feat(platform): close AI expense value loop`、`242d68c3 feat(approval): add task workflow and waiver decisions`、`28b834ed fix(approval): replay immutable action responses`、`4940ebc4 feat(approval): add safe risk disposition workflow`、`ee88a36b feat(ai): add tenant-safe hierarchical expense learning`、`6bdf65bc feat(expenses): add authoritative pre-review workflow`、`ae3f02c3 feat(expense): add persistent zero-entry receipt association`,另有 11 条。
|
||||||
|
- 原因:默认 Compose 没有管理 ONLYOFFICE DocumentServer,应用配置仍可能被根 `.env` 覆盖;服务启动后,ONLYOFFICE 内容令牌映射出的合法平台管理员机器会话又被资产访问域统一拒绝。
|
||||||
|
- 修改:默认 Compose 新增带健康检查和 JWT 的 `onlyoffice` 服务,主应用等待 PostgreSQL 与 ONLYOFFICE 均健康后启动,并分别配置浏览器公共地址和 Compose 网络内回调地址;本地运行配置不再被根 `.env` 悄悄覆盖。
|
||||||
|
- 修改:平台资产访问域仅允许真实 `is_admin=true` 的平台会话进入,并把可见范围严格限制为 `scope=platform` 且 `tenant_id=platform`;普通平台会话继续拒绝,企业管理员也不能借此跨企业访问。
|
||||||
|
- 操作:通过 `admin/admin` 在系统设置中保存本地 ONLYOFFICE 地址与匹配 JWT 密钥;Compose 重建后仅保留一个应用容器 `local-x-financial-linux`,依赖容器为 PostgreSQL 和 ONLYOFFICE,三者均为 healthy。
|
||||||
|
- 验证:规则配置、内容下载和回调链路均返回 200;真实页面成功加载公司通信费报销规则工作簿,显示完整工具栏、工作表“通信费报销标准”和 A1 内容“序号”;平台资产相关回归 36 条通过,最终后端联合回归 `65 passed`,`docker compose config --quiet` 与 `git diff --check` 通过。
|
||||||
|
- 影响:在默认 Compose 环境中无需手工启动外部文档服务即可在线查看和编辑规则 Excel,同时平台规则访问仍保持最小权限边界。
|
||||||
@@ -33,11 +33,19 @@ class AgentAssetAccessScope:
|
|||||||
@classmethod
|
@classmethod
|
||||||
def from_user(cls, current_user: CurrentUserContext) -> AgentAssetAccessScope:
|
def from_user(cls, current_user: CurrentUserContext) -> AgentAssetAccessScope:
|
||||||
tenant_id = str(current_user.tenant_id or "").strip()
|
tenant_id = str(current_user.tenant_id or "").strip()
|
||||||
if not tenant_id or tenant_id == AGENT_ASSET_PLATFORM_TENANT_ID:
|
is_platform_admin = bool(current_user.is_admin)
|
||||||
|
if not tenant_id or (
|
||||||
|
tenant_id == AGENT_ASSET_PLATFORM_TENANT_ID and not is_platform_admin
|
||||||
|
):
|
||||||
raise PermissionError("当前登录会话缺少有效租户。")
|
raise PermissionError("当前登录会话缺少有效租户。")
|
||||||
return cls(tenant_id=tenant_id, is_platform_admin=bool(current_user.is_admin))
|
return cls(tenant_id=tenant_id, is_platform_admin=is_platform_admin)
|
||||||
|
|
||||||
def visibility_clause(self, model: Any) -> Any:
|
def visibility_clause(self, model: Any) -> Any:
|
||||||
|
if self.tenant_id == AGENT_ASSET_PLATFORM_TENANT_ID:
|
||||||
|
return and_(
|
||||||
|
model.scope == AGENT_ASSET_PLATFORM_SCOPE,
|
||||||
|
model.tenant_id == AGENT_ASSET_PLATFORM_TENANT_ID,
|
||||||
|
)
|
||||||
return or_(
|
return or_(
|
||||||
and_(
|
and_(
|
||||||
model.scope == AGENT_ASSET_PLATFORM_SCOPE,
|
model.scope == AGENT_ASSET_PLATFORM_SCOPE,
|
||||||
|
|||||||
@@ -693,6 +693,9 @@ def test_platform_spreadsheet_onlyoffice_requires_platform_admin_to_edit(monkeyp
|
|||||||
admin_callback_token = parse_qs(
|
admin_callback_token = parse_qs(
|
||||||
urlsplit(admin_config.config["editorConfig"]["callbackUrl"]).query
|
urlsplit(admin_config.config["editorConfig"]["callbackUrl"]).query
|
||||||
)["access_token"][0]
|
)["access_token"][0]
|
||||||
|
admin_content_token = parse_qs(
|
||||||
|
urlsplit(admin_config.config["document"]["url"]).query
|
||||||
|
)["access_token"][0]
|
||||||
finance_session = service.validate_rule_spreadsheet_access_token(
|
finance_session = service.validate_rule_spreadsheet_access_token(
|
||||||
rule.id,
|
rule.id,
|
||||||
finance_callback_token,
|
finance_callback_token,
|
||||||
@@ -708,6 +711,33 @@ def test_platform_spreadsheet_onlyoffice_requires_platform_admin_to_edit(monkeyp
|
|||||||
assert admin_session.tenant_id == "platform"
|
assert admin_session.tenant_id == "platform"
|
||||||
assert admin_session.resource_scope == "platform"
|
assert admin_session.resource_scope == "platform"
|
||||||
assert admin_session.actor == "username:platform_admin"
|
assert admin_session.actor == "username:platform_admin"
|
||||||
|
|
||||||
|
content_session = service.validate_rule_spreadsheet_access_token(
|
||||||
|
rule.id,
|
||||||
|
admin_content_token,
|
||||||
|
)
|
||||||
|
onlyoffice_service = AgentAssetService(
|
||||||
|
db,
|
||||||
|
current_user=CurrentUserContext(
|
||||||
|
username=content_session.actor,
|
||||||
|
name="ONLYOFFICE",
|
||||||
|
role_codes=["manager"],
|
||||||
|
is_admin=True,
|
||||||
|
tenant_id=content_session.tenant_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
content_path, content_type, content_name = (
|
||||||
|
onlyoffice_service.get_rule_spreadsheet_content(
|
||||||
|
rule.id,
|
||||||
|
validated_session=content_session,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assert content_path.exists()
|
||||||
|
assert content_type == (
|
||||||
|
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||||
|
)
|
||||||
|
assert content_name.endswith(".xlsx")
|
||||||
|
|
||||||
with pytest.raises(AgentAssetOnlyOfficeSecurityError, match="不匹配"):
|
with pytest.raises(AgentAssetOnlyOfficeSecurityError, match="不匹配"):
|
||||||
service.validate_rule_spreadsheet_access_token(
|
service.validate_rule_spreadsheet_access_token(
|
||||||
"different-asset",
|
"different-asset",
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ from app.models.agent_asset import AgentAsset, AgentAssetTestRun, AgentAssetVers
|
|||||||
from app.models.financial_record import ExpenseClaim
|
from app.models.financial_record import ExpenseClaim
|
||||||
from app.models.tenant import Tenant
|
from app.models.tenant import Tenant
|
||||||
from app.schemas.agent_asset import AgentAssetRiskRuleScenarioTestRequest
|
from app.schemas.agent_asset import AgentAssetRiskRuleScenarioTestRequest
|
||||||
from app.services.agent_asset_access import stable_user_principal
|
from app.services.agent_asset_access import AgentAssetAccessScope, stable_user_principal
|
||||||
from app.services.agent_asset_onlyoffice_security import (
|
from app.services.agent_asset_onlyoffice_security import (
|
||||||
AgentAssetOnlyOfficeSessionService,
|
AgentAssetOnlyOfficeSessionService,
|
||||||
)
|
)
|
||||||
@@ -173,6 +173,43 @@ def test_asset_reads_are_authenticated_and_tenant_scoped() -> None:
|
|||||||
assert anonymous.status_code == 401
|
assert anonymous.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_platform_tenant_scope_requires_real_admin_and_is_platform_only() -> None:
|
||||||
|
non_admin = CurrentUserContext(
|
||||||
|
username="platform-manager",
|
||||||
|
name="平台经理",
|
||||||
|
role_codes=["manager"],
|
||||||
|
is_admin=False,
|
||||||
|
tenant_id="platform",
|
||||||
|
)
|
||||||
|
with pytest.raises(PermissionError, match="有效租户"):
|
||||||
|
AgentAssetAccessScope.from_user(non_admin)
|
||||||
|
|
||||||
|
platform_admin = CurrentUserContext(
|
||||||
|
username="platform-admin",
|
||||||
|
name="平台管理员",
|
||||||
|
role_codes=["manager"],
|
||||||
|
is_admin=True,
|
||||||
|
tenant_id="platform",
|
||||||
|
)
|
||||||
|
access_scope = AgentAssetAccessScope.from_user(platform_admin)
|
||||||
|
|
||||||
|
factory = _factory()
|
||||||
|
with factory() as db:
|
||||||
|
assets = _seed(db)
|
||||||
|
visible_assets = list(
|
||||||
|
db.scalars(
|
||||||
|
select(AgentAsset).where(access_scope.visibility_clause(AgentAsset))
|
||||||
|
).all()
|
||||||
|
)
|
||||||
|
|
||||||
|
assert [asset.id for asset in visible_assets] == [assets["platform"].id]
|
||||||
|
assert access_scope.can_write(assets["platform"]) is True
|
||||||
|
assert access_scope.can_write(assets["tenant-a"]) is False
|
||||||
|
access_scope.require_write(assets["platform"])
|
||||||
|
with pytest.raises(LookupError, match="Asset not found"):
|
||||||
|
access_scope.require_write(assets["tenant-a"])
|
||||||
|
|
||||||
|
|
||||||
def test_version_write_uses_stable_principal_and_blocks_cross_tenant_or_platform() -> None:
|
def test_version_write_uses_stable_principal_and_blocks_cross_tenant_or_platform() -> None:
|
||||||
factory = _factory()
|
factory = _factory()
|
||||||
with factory() as db:
|
with factory() as db:
|
||||||
|
|||||||
Reference in New Issue
Block a user