管理员访问控制
本指南说明后台 super_admin / sub_admin 访问控制的当前实现边界,以及前后端如何协同工作。
目标
- 为管理后台提供独立的管理员身份体系,而不是复用普通业务会话。
- 让
super_admin与sub_admin在服务端拥有明确且可审计的权限边界。 - 对敏感操作引入密码重输 challenge,并把成功、失败、拒绝访问都写入审计日志。
认证模型
- 后台使用 same-origin HttpOnly Cookie 会话。
POST /admin/auth/login会同时签发 access cookie 与 refresh cookie。- access token 现在绑定到服务端
admin_refresh_tokens会话记录,而不是纯粹的无状态签名令牌。 POST /admin/auth/refresh会轮换 refresh token,并同时使旧 access token 失效。- refresh token 轮换现在是严格单次消费语义;即使并发重复提交同一个原始 refresh token,也只允许一个请求成功。
POST /admin/auth/logout会撤销当前 refresh token、连带使当前 access token 失效,并清除 cookie。- 管理员密码被重置后,服务端会撤销该账号所有活跃会话,对应 access token 也会立刻失效。
GET /admin/auth/me返回当前管理员资料与有效权限列表,供前端恢复导航和路由保护状态。/openapi.json、/docs与/redoc也被纳入后台登录态保护,不再对匿名访问开放。- 已建立的
WS /ws/{session_id}连接现在也会在服务端会话被 logout / revoke 后重新校验;失效连接不会继续接收或发送实时控制面消息。
运行期安全要求
- 生产环境必须显式配置
ADMIN_ACCESS_TOKEN_SECRET_KEY,不能继续使用占位默认值。 - 生产环境必须配置
ADMIN_ACCESS_COOKIE_SECURE=true,否则服务启动会失败。 - 非生产环境如果没有显式配置管理员 token secret,服务会在启动时生成一个临时 secret;这样可以避免继续使用可预测的默认值,但进程重启后旧 cookie 会失效。
- 首个 bootstrap
super_admin仍通过ADMIN_ACCESS_BOOTSTRAP_EMAIL、ADMIN_ACCESS_BOOTSTRAP_USERNAME、ADMIN_ACCESS_BOOTSTRAP_PASSWORD创建;当这三项只配置了部分字段时,启动会直接失败,而不是静默忽略。
角色与权限
super_admin默认拥有完整权限目录,无需逐条维护 grant。sub_admin的有效权限来自admin_permission_grants中is_active=true的记录。- 创建子管理员账号只要求
admin_users.write;如果要在创建时附带初始 grant,操作者还必须额外拥有admin_permissions.write,并完成一次密码重输 challenge。 - 即使操作者拥有
admin_permissions.write,也不能通过权限授予接口给自己追加新权限;自我提权会被拒绝并写入审计。 - 管理员
role_code/status变更只允许super_admin执行,普通子管理员不能把自己或其他账号提升为super_admin。 - 角色或状态变更在通过
super_admin边界后,还必须再完成一次密码重输 challenge,避免单纯依赖已登录 Cookie 直接提升新超级管理员。 - 管理员也不能通过标准资料更新接口修改自己的
role_code/status。 - 当标准更新、删除或密码重置的目标是
super_admin时,操作者自身也必须是super_admin;普通子管理员只能查看这类账号,不能通过标准路径接管或删除它们。 GET /sessions/{session_id}/turns/{turn_id}/context虽然属于会话观测接口,但因为会返回 MCP / Skill trace 载荷,必须同时具备conversations.read、mcp.read与skills.read。GET /sessions/{session_id}/turns/{turn_id}/correction需要同时具备conversations.read与knowledge.read;读取完整纠错草稿不再要求knowledge.write。GET /knowledge-sources/{source_id}/corrections也必须同时具备knowledge.read与conversations.read,因为它会暴露由具体会话/turn 产生的纠错账本。GET /sessions/{session_id}/turns与GET /sessions/{session_id}/turns/{turn_id}/context仍以会话观测权限为主,但嵌入的turn.correction摘要会根据权限做字段级脱敏:没有knowledge.read/knowledge.write时,只保留状态与修订信息,不返回source_id、document_id、ingestion_job_*或错误详情。- 权限目录覆盖当前控制面模块,例如:
dashboard.readagents.read/agents.write/agents.deleteknowledge.read/knowledge.writeevaluation.read/evaluation.writemcp.read/mcp.writeadmin_users.read/admin_users.write/admin_users.deleteadmin_permissions.writeaudit.readsystem_settings.read/system_settings.writesecurity.break_glass
敏感操作 Challenge
以下操作要求先完成一次密码重输 challenge:
- 创建子管理员时附带初始权限
- 变更管理员角色或状态
- 删除管理员
- break-glass 删除受保护超级管理员
- 授予或撤销子管理员权限
- 重置管理员密码
- 修改后台系统设置
Document Recognition runtime registry 的添加/移除不属于上述密码重输 challenge 清单;它仍要求管理员登录态和 document_recognition.write,并写入审计日志。
对于系统设置错误输入:
- 未知
config_key返回404 - 类型不匹配或超出允许范围的值返回
400 - 这两类失败也会写入
admin_action_audits
典型流程:
- 前端调用
POST /admin/auth/challenges,提交purpose_code - 前端弹出后台内置的密码确认对话框,并以掩码输入方式重新采集管理员密码
- 前端调用
POST /admin/auth/challenges/{challenge_id}/verify - 服务端返回短时
challenge_token - 前端在后续敏感请求中通过
x-admin-challenge-token请求头传回
额外约束:
- 一旦 challenge verify 因密码或 nonce 校验失败,当前 challenge 会立即失效,必须重新申请新的 challenge。
- 管理员创建、更新、删除、权限变更、密码重置与系统设置写入的成功路径,会把业务变更和成功审计放进同一个数据库事务里提交,避免留下“改动成功但审计缺失”的状态。
审计
后台安全相关事件统一写入 admin_action_audits,包括:
- 登录成功与登录失败
- 控制面 RBAC 拒绝访问
- challenge token 缺失、失效或重复使用导致的敏感操作失败
- 管理员资料变更
- 权限授予与撤销
- 管理员密码重置
- 系统设置修改成功与失败
- 标准删除与 break-glass 删除
- 自删拒绝、自我提权拒绝、错误 break-glass 目标等策略拒绝
推荐排查路径:
- 先在
Audit Logs页面按result_code过滤failed、forbidden、unauthenticated - 再按
module_code或action_code缩小范围 - 最后查看单条审计记录的
before_state、after_state与metadata
密码遗失恢复
当前系统没有“忘记密码 -> 邮件找回”这类自助恢复流程。
- 如果还有其他管理员能够登录,应优先由现有管理员通过标准接口执行密码重置。
- 如果目标账号是
super_admin,执行重置的操作者也必须是super_admin。 - 如果已经没有任何管理员知道当前密码,不能依赖再次设置
ADMIN_ACCESS_BOOTSTRAP_*来恢复,因为 bootstrap 只会在管理员总数为0时创建首个管理员。 - 对于“仍然保持登录态但忘记密码”的场景,敏感操作仍然要求密码重输 challenge,因此通常也不能直接在前端自救。
推荐恢复方式是通过服务器或容器终端执行一次离线密码重置。
先列出现有管理员
Docker Compose 场景:
docker compose exec -T ai-service python - <<'PY'
from ai_service.utils.database import SessionLocal
from ai_service.storage.models import list_admin_user_records
db = SessionLocal()
try:
for user in list_admin_user_records(db):
print(
f"id={user.id} username={user.username} email={user.email} "
f"role={user.role_code} status={user.status} protected={user.is_protected}"
)
finally:
db.close()
PY
本地 uv run python main.py 场景:
uv run python - <<'PY'
from ai_service.utils.database import SessionLocal
from ai_service.storage.models import list_admin_user_records
db = SessionLocal()
try:
for user in list_admin_user_records(db):
print(
f"id={user.id} username={user.username} email={user.email} "
f"role={user.role_code} status={user.status} protected={user.is_protected}"
)
finally:
db.close()
PY
离线重置现有管理员密码
先确认目标 username,然后执行下面的命令。新密码必须至少 12 个字符。
Docker Compose 场景:
docker compose exec -T \
-e TARGET_USERNAME=admin \
-e NEW_PASSWORD='NewStrongPassword123!' \
ai-service python - <<'PY'
import os
from ai_service.services.admin.auth import get_admin_auth_service
from ai_service.storage.models import get_admin_user_record_by_username
from ai_service.utils.database import SessionLocal
db = SessionLocal()
try:
username = os.environ["TARGET_USERNAME"]
new_password = os.environ["NEW_PASSWORD"]
admin = get_admin_user_record_by_username(db, username)
if admin is None:
raise SystemExit(f"admin not found: {username}")
get_admin_auth_service().reset_admin_password(
db_session=db,
admin_user=admin,
new_password=new_password,
updated_by_admin_id=admin.id,
)
print(f"password reset ok for username={admin.username}")
finally:
db.close()
PY
本地 uv run python main.py 场景:
TARGET_USERNAME=admin NEW_PASSWORD='NewStrongPassword123!' uv run python - <<'PY'
import os
from ai_service.services.admin.auth import get_admin_auth_service
from ai_service.storage.models import get_admin_user_record_by_username
from ai_service.utils.database import SessionLocal
db = SessionLocal()
try:
username = os.environ["TARGET_USERNAME"]
new_password = os.environ["NEW_PASSWORD"]
admin = get_admin_user_record_by_username(db, username)
if admin is None:
raise SystemExit(f"admin not found: {username}")
get_admin_auth_service().reset_admin_password(
db_session=db,
admin_user=admin,
new_password=new_password,
updated_by_admin_id=admin.id,
)
print(f"password reset ok for username={admin.username}")
finally:
db.close()
PY
恢复完成后:
- 立即用新密码登录后台,确认账号可用。
- 由于密码重置会撤销该账号原有活跃会话,原浏览器登录态需要重新登录。
- 建议保留至少两个
super_admin,避免单点密码丢失导致再次需要离线恢复。
前端控制面
admin-frontend 现在具备以下访问控制能力:
- 登录页与受保护路由边界
- 会话恢复与自动 refresh 尝试
- 按权限隐藏导航项
- 管理员目录页
- 审计日志页
- 系统设置页
注意:
- 前端隐藏只是体验优化,服务端权限校验仍然是真相源。
- 公开例外路径当前仅保留
/healthz、/stream与/models。 - 管理员 challenge 现在使用后台内置的掩码密码确认对话框,不再依赖浏览器原生
window.prompt()。 .env、第三方密钥和部署级基础设施参数不在当前后台设置页的编辑范围内。