访问密钥收尾:服务账号进授权不进岗位的测试、关停时写请求记录的测试,几处整理

- 补测试:应用授权收服务账号、岗位绑定的检查把它算成错;改派给服务账号 422;调度循环停下时把攒着的请求记录写进库
  (去掉对应的代码回跑,测试抓得住)
- 门禁里只给拥有者的运行统一按 auth/access.PRIVATE_ORIGINS 认,不再各写一份字面量
- 幂等的 once 只把普通异常记成被拒:请求被取消时记录停在处理中,过了时限同一个键能重新认领(同钩子)
- 新建密钥的弹窗等服务账号取回来再说「还不能建」,不先闪一下
This commit is contained in:
ThearchyHelios
2026-10-16 14:15:51 +08:00
parent 0b825f40ee
commit 545babc541
6 changed files with 48 additions and 9 deletions
+2 -1
View File
@@ -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)]
+6 -2
View File
@@ -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"))
+2 -3
View File
@@ -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
+3
View File
@@ -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", "改派给服务账号拒收"
# ---------------------------------------------------------------- 封顶在使用者
+31
View File
@@ -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]
@@ -315,7 +315,8 @@ function KeyCreator({ settings, onClose, onIssued }: {
onClose: () => void
onIssued: (k: AccessKeyIssued) => void
}) {
const [services, setServices] = useState<ServiceAccountRow[]>([])
// null:还在取(管理员才取);取到之后才判「还不能建」
const [services, setServices] = useState<ServiceAccountRow[] | null>(settings.admin ? null : [])
const [apps, setApps] = useState<AppSummary[] | null>(null)
// 拥有者:'' 是我自己(个人访问密钥),否则是服务账号 id
const [owner, setOwner] = useState<string | null>(settings.can_create_personal ? '' : null)
@@ -364,13 +365,13 @@ function KeyCreator({ settings, onClose, onIssued }: {
</button>
</>}>
<div className="space-y-3" data-access-key-form="">
{owner === null && !services.length && <Notice tone="warn">{T.noOwner}</Notice>}
{owner === null && services !== null && !services.length && <Notice tone="warn">{T.noOwner}</Notice>}
{settings.admin && (
<Field label={T.owner}>
{(p) => (
<select {...p} className="field" value={owner ?? ''} onChange={(e) => setOwner(e.target.value)} data-access-key-owner="">
{settings.can_create_personal && <option value="">{T.ownerMe}</option>}
{services.map((s) => <option key={s.id} value={s.id}>{T.ownerService(s.username)}</option>)}
{(services ?? []).map((s) => <option key={s.id} value={s.id}>{T.ownerService(s.username)}</option>)}
</select>
)}
</Field>