From 545babc5416289113a791f9c30b488241b8cf83f Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Fri, 16 Oct 2026 14:15:51 +0800 Subject: [PATCH] =?UTF-8?q?=E8=AE=BF=E9=97=AE=E5=AF=86=E9=92=A5=E6=94=B6?= =?UTF-8?q?=E5=B0=BE=EF=BC=9A=E6=9C=8D=E5=8A=A1=E8=B4=A6=E5=8F=B7=E8=BF=9B?= =?UTF-8?q?=E6=8E=88=E6=9D=83=E4=B8=8D=E8=BF=9B=E5=B2=97=E4=BD=8D=E7=9A=84?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=E3=80=81=E5=85=B3=E5=81=9C=E6=97=B6=E5=86=99?= =?UTF-8?q?=E8=AF=B7=E6=B1=82=E8=AE=B0=E5=BD=95=E7=9A=84=E6=B5=8B=E8=AF=95?= =?UTF-8?q?=EF=BC=8C=E5=87=A0=E5=A4=84=E6=95=B4=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 补测试:应用授权收服务账号、岗位绑定的检查把它算成错;改派给服务账号 422;调度循环停下时把攒着的请求记录写进库 (去掉对应的代码回跑,测试抓得住) - 门禁里只给拥有者的运行统一按 auth/access.PRIVATE_ORIGINS 认,不再各写一份字面量 - 幂等的 once 只把普通异常记成被拒:请求被取消时记录停在处理中,过了时限同一个键能重新认领(同钩子) - 新建密钥的弹窗等服务账号取回来再说「还不能建」,不先闪一下 --- backend/app/api/tasks.py | 3 +- backend/app/auth/keys.py | 8 +++-- backend/app/auth/middleware.py | 5 ++- backend/tests/test_access_key_use.py | 3 ++ backend/tests/test_access_keys.py | 31 +++++++++++++++++++ frontend/src/pages/settings/AccessKeysTab.tsx | 7 +++-- 6 files changed, 48 insertions(+), 9 deletions(-) diff --git a/backend/app/api/tasks.py b/backend/app/api/tasks.py index 27224f56..088e6836 100644 --- a/backend/app/api/tasks.py +++ b/backend/app/api/tasks.py @@ -72,6 +72,7 @@ async def _for_key(principal: Any, items: list[dict[str, Any]]) -> list[dict[str return items from sqlalchemy import select + from app.auth.access import PRIVATE_ORIGINS from app.db.base import SessionLocal from app.db.models import App, Run @@ -81,7 +82,7 @@ async def _for_key(principal: Any, items: list[dict[str, Any]]) -> list[dict[str personal = set((await session.execute(select(App.id).where(App.id.in_(app_ids or {""}), App.scope == "personal"))).scalars()) private = set((await session.execute(select(Run.id).where( - Run.id.in_(run_ids or {""}), Run.origin_kind.in_(("personal_app", "trial"))))).scalars()) + Run.id.in_(run_ids or {""}), Run.origin_kind.in_(PRIVATE_ORIGINS)))).scalars()) return [i for i in items if i.get("app_id") not in personal and i.get("run_id") not in private and (principal.app_ids is None or i.get("app_id") in principal.app_ids)] diff --git a/backend/app/auth/keys.py b/backend/app/auth/keys.py index b1d9b0a8..1d4c5dc3 100644 --- a/backend/app/auth/keys.py +++ b/backend/app/auth/keys.py @@ -397,8 +397,10 @@ async def _check_task(session: Any, principal: Any, task_id: str) -> None: elif principal.app_ids is not None: # 不属于应用的待办(画布、定时任务里的审批):限定了应用的密钥碰不到 raise _forbidden(APP_FORBIDDEN_TEXT, KEY_APP_FORBIDDEN) + from app.auth.access import PRIVATE_ORIGINS + run = await session.get(Run, task.run_id) if task.run_id else None - if run is not None and (getattr(run, "origin_kind", None) or "") in ("personal_app", "trial"): + if run is not None and (getattr(run, "origin_kind", None) or "") in PRIVATE_ORIGINS: raise _forbidden(APP_PERSONAL_TEXT, KEY_APP_PERSONAL) @@ -519,7 +521,9 @@ async def once(principal: Any, idem_key: str | None, *, route: str, request: Any return await replay(previous) try: out = await run(reclaimed) - except BaseException as e: + except Exception as e: + # 被拒(表单不对、发起不了):记成 rejected,同一个键可以重试。请求被取消(客户端断开)不在这里收:记录停在 + # processing,过了 STALE_S 同一个键能重新认领(同钩子) await book.finish(owner, key, status="rejected", detail=(str(e) or type(e).__name__)[:500]) raise note_run(out.get("run_id")) diff --git a/backend/app/auth/middleware.py b/backend/app/auth/middleware.py index 00200f72..c5cbed02 100644 --- a/backend/app/auth/middleware.py +++ b/backend/app/auth/middleware.py @@ -112,13 +112,12 @@ class AuthMiddleware: await self.app(scope, receive, send) return from app.api.deps import reset_principal, set_principal - from app.auth import current_mode, refusal + from app.auth import current_mode, keys, refusal # 登录设置在启动时读进缓存(main.py 的 _platform_startup),改设置的接口改完就刷新;这里不查库 mode = current_mode() headers = _headers(scope) - from app.auth import keys - + # 带访问密钥的请求另走一条路:只按密钥认,不看 cookie、不查 CSRF(见模块说明) if (secret := keys.bearer(headers)) is not None: await self._with_key(scope, receive, send, secret, mode) return diff --git a/backend/tests/test_access_key_use.py b/backend/tests/test_access_key_use.py index 3165f432..5f09419a 100644 --- a/backend/tests/test_access_key_use.py +++ b/backend/tests/test_access_key_use.py @@ -287,6 +287,9 @@ async def test_service_accounts_are_never_assigned_tasks(world): _, secret = await auth_env.make_key(svc, scopes=("tasks.read", "tasks.respond")) r = await auth_env.keyed(secret).post(f"/api/tasks/{task.id}/respond", json={"decision": "approve"}) assert r.json()["code"] == "key_scope_missing", "服务账号的密钥没有回复待办这一项" + admin = await auth_env.login("root") + r = await admin.post(f"/api/tasks/{task.id}/reassign", json={"to": [{"kind": "user", "name": "svc-portal"}]}) + assert r.status_code == 422 and r.json()["code"] == "reassign_service", "改派给服务账号拒收" # ---------------------------------------------------------------- 封顶在使用者 diff --git a/backend/tests/test_access_keys.py b/backend/tests/test_access_keys.py index 28a6974d..43760d0e 100644 --- a/backend/tests/test_access_keys.py +++ b/backend/tests/test_access_keys.py @@ -339,3 +339,34 @@ async def test_service_accounts_are_marked_in_the_directory_and_kept_out_of_assi assert set(await _members(s, "ops")) == {"ana"}, "候选池里不列服务账号" assert "svc-bi" not in (await review_pool(s)).users + + +async def test_grants_take_service_accounts_but_slot_bindings_do_not(): + """服务账号能拿应用授权(外部系统按授权读应用),岗位绑定里算错:派给它的待办没人能处理。""" + from app.api.apps import grants_problem + from app.apps.lint import _Issues, _lint_slot_principals + + await auth_env.turn_on_local() + await auth_env.make_service("svc-bi") + async with SessionLocal() as s: + wanted, problem = await grants_problem(s, [("user", "svc-bi", "observe")]) + assert problem is None and wanted == [("user", "svc-bi", "observe")] + out = _Issues() + await _lint_slot_principals(s, {"approver": {"label": "审批人"}}, + {"approver": [{"kind": "user", "name": "svc-bi"}]}, out) + assert [(i["level"], i["code"], i["problem"]) for i in out.items] == [ + ("error", "app.slot_principal_unknown", "service")] + assert "服务账号" in out.items[0]["message"] + + +async def test_scheduler_stop_flushes_the_request_buffer(): + """关停时还攒在进程里的请求记录写进库(没起调度循环时也写)。""" + from app.scheduler.loop import runner + + await auth_env.turn_on_local() + svc = await auth_env.make_service() + row, secret = await auth_env.make_key(svc) + await auth_env.keyed(secret).get("/api/auth/me") + await runner.stop() + async with SessionLocal() as s: + assert [r.key_id for r in (await s.execute(select(AccessKeyRequest))).scalars()] == [row.id] diff --git a/frontend/src/pages/settings/AccessKeysTab.tsx b/frontend/src/pages/settings/AccessKeysTab.tsx index d291a6f4..b7b51670 100644 --- a/frontend/src/pages/settings/AccessKeysTab.tsx +++ b/frontend/src/pages/settings/AccessKeysTab.tsx @@ -315,7 +315,8 @@ function KeyCreator({ settings, onClose, onIssued }: { onClose: () => void onIssued: (k: AccessKeyIssued) => void }) { - const [services, setServices] = useState([]) + // null:还在取(管理员才取);取到之后才判「还不能建」 + const [services, setServices] = useState(settings.admin ? null : []) const [apps, setApps] = useState(null) // 拥有者:'' 是我自己(个人访问密钥),否则是服务账号 id const [owner, setOwner] = useState(settings.can_create_personal ? '' : null) @@ -364,13 +365,13 @@ function KeyCreator({ settings, onClose, onIssued }: { }>
- {owner === null && !services.length && {T.noOwner}} + {owner === null && services !== null && !services.length && {T.noOwner}} {settings.admin && ( {(p) => ( )}