2058 words
10 minutes
mini-agent 多租户隔离技术详解:让用户身份贯穿认证、执行与配额

mini-agent 多租户隔离技术详解:让用户身份贯穿认证、执行与配额#

问题不在登录页,而在登录之后#

单用户服务常从一个共享密码开始:校验密码,发一个 token,后面的请求只要带着 token 就能继续。这种模型没有错,前提是所有请求都属于同一个人。一旦 Web API 面向不可信用户开放,token 不能再只是“进门凭证”。它必须还原成用户 ID 和角色,并跟着请求走到会话历史、任务工作目录、文件系统 sandbox 和用量统计。

mini-agent 的多租户改造因此没有把“多用户”做成某个前端功能,而是把身份传播做成 WebChannel 的基础设施。目标不是让两个用户看见不同的按钮,而是确保两人的请求在任何一层都没有回到共享默认值。

四张表定义用户边界#

用户、会话、邀请码和配额存放在 data/users.db。它没有引入外部身份服务,UserManager 直接负责 SQLite 访问。这是单实例服务的一个刻意取舍:数据模型集中,部署简单,但容量与并发上限也需要在后续运行中观察。

关键字段它解决的问题
usersidemailpassword_hashroledisabled_at邮箱唯一,密码用 argon2id 哈希保存,账号可软封禁。
sessionstokenuser_idexpires_atrevoked_attoken 是可过期、可吊销的登录态,而不是无法撤回的共享口令。
invite_codescodemax_usesuse_countexpires_at注册不是开放入口;邀请码能限制次数和有效期。
quota_usageuser_idbuckettask_callsquery_callschat_callspi_seconds以用户和日期为键记录请求数与执行时长。

首启时,服务根据配置创建第一个 admin。此后注册只会创建 userdisabled_at 不为空时,登录被拒绝;管理员封禁账号时,还会通过 revoke_all_user_sessions(user_id) 吊销该用户已有的会话。

邀请码的消费和用户创建放在同一个事务中。下面是把实现压缩后的关键逻辑:

with conn:
user_id = insert_user(email, hash_password(password), role="user")
changed = execute(
"""UPDATE invite_codes
SET use_count = use_count + 1,
used_by = COALESCE(used_by, ?)
WHERE code = ?
AND use_count < max_uses
AND (expires_at IS NULL OR expires_at >= ?)""",
(user_id, invite_code, now),
)
if changed.rowcount != 1:
raise InvalidInvite()

先插用户、再消费邀请码并不意味着失败会留下半条用户记录:两步处在同一个事务里,邀请码无效时会一起回滚。并发注册时,也只有成功更新到 use_count 的请求能拿到账号。

请求链先认证,再保留,再计量#

多租户模式启动 WebChannel 时,middleware 顺序是 CORS、认证、配额。认证中间件跳过 /auth/register 等公开端点;其他请求都必须先从 Authorization: Bearer <token> 找到有效 session,再把身份写进 request:

async def auth_middleware(request, handler):
if request.path in PUBLIC_PATHS:
return await handler(request)
token = extract_bearer_token(request)
user = user_manager.validate_session(token)
if user is None:
return unauthorized()
request["token"] = token
request["user"] = {"id": user["id"], "role": user["role"]}
return await handler(request)

validate_session() 同时检查 revoked_atdisabled_atexpires_at。会话接近到期时会滑动续期;因此客户端恢复本地 token 后还会调用 /auth/status,而不是只相信 localStorage

角色检查不放在前端。处理少数管理员端点时,服务端读取 request["user"]["role"]/admin/*、待办、日程和话题能力要求 admin,普通用户得到 403。浏览器把按钮改为可见,或 APK 伪造一个“admin”标签,都改变不了这一判断。

会话隔离要落到线程名#

对话上下文是最容易漏掉的共享状态。多租户前,Web 线程可由 token 派生为 web_<token[:16]>。多租户后,同一规则被包进用户命名空间:

def default_thread(user_id: int, token: str) -> str:
return f"u{user_id}_web_{token[:16]}"
def thread_for(request) -> str:
return active_thread.get(request["user"]["id"], default_thread(...))

/history 使用当前用户的线程前缀读取记录,活动话题和日志偏移也按用户 ID 保存。这样,token 轮换不会把不同用户的上下文拼到一起;同一个用户在不同登录会话里也仍然处于自己的命名空间。

这里的原则很朴素:不要接受客户端传来的 user_id 当作查询条件。服务端只从已验证 token 推导用户,再用这个用户 ID 构造线程和过滤条件。

工作区不是目录约定,而是 sandbox 的输入#

/query/task 会把不可信文本交给 Pi 执行。即使聊天记录已经隔离,如果所有 Pi 子进程仍在同一目录运行,用户依旧可能通过工具调用触碰其他用户或应用本身的文件。

执行器为多租户请求计算工作目录:

def workspace_for(user_id: int | None) -> Path | None:
if user_id is None:
return legacy_pi_workspace
return Path("data/workspaces") / str(user_id)
async def run_pi(prompt, user_id):
cwd = workspace_for(user_id)
return PiRpcClient(cwd=cwd, user_workspace=str(cwd), sandbox=config)

user_workspace 随后进入 bwrap 参数构造。普通用户能写自己的 data/workspaces/<user_id>,但不会获得其他用户工作区、共享项目目录、博客仓库或应用源码的可写挂载。多租户启动时如果无法使用 bwrap 文件系统约束,服务会 fail closed;不会为了“先跑起来”退化成只靠环境变量的 sandbox。

图里的“不可见或不可写”取决于具体挂载策略,但结论不变:工作区路径由服务端身份选择,不能由请求 body 指定。工作区会在保留期后清理,避免旧租户目录永久堆积。

配额要在入口原子预留,在出口补执行时长#

配额有两个不同的计量时点。chat_callsquery_callstask_calls 在请求进入 handler 前原子预留;这比“先查当前数量,执行后再加一”更可靠,后者会让并发请求同时穿过同一个上限。

def consume_quota(user_id: int, key: str, limit: int) -> bool:
bucket = today_utc()
with conn:
insert_usage_if_missing(user_id, bucket)
changed = execute(
f"""UPDATE quota_usage
SET {key} = {key} + 1
WHERE user_id = ? AND bucket = ? AND {key} < ?""",
(user_id, bucket, limit),
)
return changed.rowcount == 1

没有预留成功时,middleware 返回 429{"error":"quota_exceeded","bucket":...}Retry-After。Web Chat 页把它显示为当日额度耗尽;客户端不会自己计数或自行决定放行。

pi_seconds 只能在任务结束后知道,因此走另一条路径:SSE 和普通响应的执行外层记录开始时间,并在 finally 中写入 record_usage(user_id, pi_seconds=...)。客户端中断连接也会进入这段清理逻辑,避免把已消耗的执行时间漏记。它是软上限:达到时拒绝下一次请求,不会中途终止一个已运行的 Pi 进程。

设计边界仍然存在#

这套设计把隔离约束集中在后端,但它不是“做完就没有风险”。当前实现是单实例 SQLite 和单机 Pi 执行器;用户数量、并发任务和工作区大小增长后,需要重新评估锁竞争、磁盘清理与执行队列。配额阈值也需要按真实 ECS 的资源和任务形态校准,不能从代码里猜一个永久正确的数字。

更重要的是,sandbox 可用性是运行时事实。测试可以验证 user_id 被传入、挂载列表符合策略、超额请求返回 429;ECS 上仍要持续确认 bwrap 可用、配置没有回退、普通用户看不到共享路径。把这一层保留为部署检查项,才符合 fail-closed 的原意。

服务如何把这些后端边界投射到本地实例、博客 Chat 页和管理员 APK,见工程复盘

参考资料#

mini-agent 多租户隔离技术详解:让用户身份贯穿认证、执行与配额
https://sgjki547.top/posts/2026-08-02-mini-agent-多租户隔离技术详解/
Author
SGJki
Published at
2026-08-02
License
CC BY-NC-SA 4.0