Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions app/admin_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,9 @@ def install_admin(app, config, gateway):
return app.state.admin_auth
control, audit = config["control_store"], config["audit_store"]
auth = AdminAuth(config)
# Resolve the key epoch now: a process that only serves inference traffic would
# otherwise never clear a snapshot belonging to a superseded key.
auth.reconcile()
mutation_lock = threading.RLock()
oauth_lock = threading.RLock()
oauth_tasks = OrderedDict()
Expand Down Expand Up @@ -102,7 +105,8 @@ def settings_result():
items.append({"key": "auto_accept_buddy", "value": config.get("auto_accept_buddy") is True,
"stored": None, "source": config.get("auto_accept_buddy_source", "default"),
"mode": "startup", "type": "boolean", "label": "全部国内账号首次领猫预授权", "locked": True})
return {"revision": control.snapshot()["revision"], "items": items, "audit": audit.storage()}
return {"revision": control.snapshot()["revision"], "items": items, "audit": audit.storage(),
"session": auth.storage()}

def audit_settings(values):
mapping = {"audit_max_bytes": "max_bytes", "audit_retention_days": "retention_days",
Expand Down Expand Up @@ -220,7 +224,10 @@ async def session_get(request):

@route("DELETE", "/admin/session")
async def session_delete(request):
auth.logout(request)
if not auth.logout(request):
# The session is still valid on disk; keep the cookie so the client can retry.
event("session.revoke_failed", {"code": "session_storage_unavailable"})
return error_response(503, "会话未能持久撤销,请检查管理目录权限后重试")
response = JSONResponse({"authenticated": False})
response.delete_cookie(COOKIE_NAME, path="/admin", httponly=True, samesite="strict", secure=request.url.scheme == "https")
return response
Expand Down
305 changes: 296 additions & 9 deletions app/admin_auth.py

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions app/runtime_management.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,9 @@ def initialize(gateway, args, argv=None, *, parser=None):
root = gateway.managed_auth_dir()
control = ControlStore(root / "control.sqlite3")
config["control_store"] = control
# Persist management sessions so a restart does not force another API-key login.
# The file stores an HMAC fingerprint of the key epoch, so key rotation still revokes them.
config["session_path"] = root / "admin-sessions.json"
config.update(vars(args))
config["model_guard"] = not args.no_model_guard
aliases = {"log": "log_path", "no_model_guard": "model_guard"}
Expand Down
9 changes: 8 additions & 1 deletion converter.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ def desensitize_body(body, roles=("system",), desensitize_harness_user=False,
from app.message_normalization import merge_intl_user_images
from app.model_catalog_view import INTERNATIONAL as SHARED_INTL_PROFILES, share_models
from app.inference_auth import require_api_key
from app.admin_auth import SessionStoreError
from app.content_filter import ContentFilterDetector, is_filter_error
from app.request_limits import ImageLimitError, apply_image_policy
from app.safe_logging import format_log_body, sanitize_log_text
Expand Down Expand Up @@ -3919,7 +3920,13 @@ def main():
if CONFIG["usage_snapshots"].detail():
_publish_usage_daily(CONFIG["cred_pool"])

runtime_management.install(sys.modules[__name__])
try:
runtime_management.install(sys.modules[__name__])
except SessionStoreError as error:
# An obsolete session snapshot survived, so the new key epoch must not activate:
# a later start under the superseded key could adopt it and revive admin cookies.
runtime_management.close(CONFIG)
ap.error(str(error))
threading.Thread(target=_refresher_loop, args=(CONFIG["cred_pool"],),
daemon=True, name="cred-refresher").start()
if credits_mod is not None:
Expand Down
36 changes: 35 additions & 1 deletion tests/test_admin_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@ def setUp(self):
self.root = Path(self.enterContext(tempfile.TemporaryDirectory()))
self.store = ControlStore(self.root / "control.sqlite3")
self.addCleanup(self.store.close)
self.config = {"api_key": "synthetic-key", "control_store": self.store, "max_images": 16}
self.config = {"api_key": "synthetic-key", "control_store": self.store, "max_images": 16,
"session_path": self.root / "admin-sessions.json"}
self.audit = Mock()
self.audit.storage.return_value = {"db_bytes": 0}
self.audit.list_records.return_value = {"items": [], "next_cursor": None, "has_more": False}
Expand Down Expand Up @@ -320,6 +321,39 @@ def test_session_cookie_flags_logout_and_rotation(self):
self.assertEqual(self.client.get("/admin/settings", headers=self.headers).status_code, 401)
self.assertEqual(self.client.get("/admin/settings", headers={"X-Api-Key": "rotated-key"}).status_code, 200)

def test_logout_reports_when_the_session_could_not_be_persistently_revoked(self):
"""A logout that cannot reach durable storage must not answer `authenticated: false`."""
from unittest.mock import patch
response = self.client.post("/admin/session", json={"api_key": "synthetic-key"}, headers={"Origin": "http://testserver"})
csrf = {"Origin": "http://testserver", "X-CSRF-Token": response.json()["csrf_token"]}
old = self.client.cookies.get(COOKIE_NAME)
with patch("app.admin_auth.tempfile.mkstemp", side_effect=OSError("read-only")), \
patch("app.admin_auth.os.unlink", side_effect=OSError("read-only")):
denied = self.client.delete("/admin/session", headers=csrf)
self.assertEqual(denied.status_code, 503)
self.assertTrue(self.auth.storage()["degraded"])
# The cookie is still valid, so the client can retry rather than silently lose access.
self.assertEqual(self.client.get("/admin/settings", headers={"Cookie": f"{COOKIE_NAME}={old}"}).status_code, 200)
self.assertEqual(self.client.delete("/admin/session", headers=csrf).status_code, 200)
self.assertEqual(self.client.get("/admin/settings", headers={"Cookie": f"{COOKIE_NAME}={old}"}).status_code, 401)

def test_session_storage_state_is_reported_in_settings(self):
state = self.client.get("/admin/settings", headers=self.headers).json()["session"]
self.assertFalse(state["degraded"])
self.assertIsNone(state["last_error"])
self.assertTrue(state["path"])

def test_a_superseded_snapshot_that_cannot_be_revoked_fails_closed(self):
"""Mid-process epoch changes must deny with 503, never serve management traffic."""
from unittest.mock import patch
self.assertEqual(self.client.get("/admin/settings", headers=self.headers).status_code, 200)
self.config["api_key"] = "rotated-synthetic-key"
with patch.object(type(self.auth), "_persist", return_value=False), \
patch.object(type(self.auth), "_revoke", return_value=False):
denied = self.client.get("/admin/settings", headers=self.headers)
self.assertEqual(denied.status_code, 503)
self.assertIn("会话快照", denied.json()["error"]["message"])

def test_https_cookie_and_bounded_sessions(self):
from starlette.requests import Request
secure = self.enterContext(TestClient(self.app, base_url="https://testserver"))
Expand Down
Loading
Loading