登录成功就能读取任意任务 ID 吗?给 SaaS 读写加上租户范围

AI SaaS 工程诊断实战(十)

接口先检查用户已登录,再按任务 ID 查询结果,看起来已经"有权限"。但登录只说明身份通过了认证,还没有说明这个身份有权读取那条记录。

本文用两组本地租户数据,比较只按 ID 查询与同时限制租户范围的读写行为。全部 ID、内容和请求均为构造,仅在内存数据库运行,不对任何外部系统进行测试。AI 辅助制作,程序已本地执行。

先看一条不够完整的查询

sql 复制代码
SELECT body FROM notes WHERE id=?

若查询对象来自客户端,当前调用者碰到另一个租户的 ID,这条语句本身没有限制归属。把 ID 换成随机字符串可以增加猜测难度,但不能代替对象授权。

OWASP 对对象级授权的说明也强调:接收对象标识并访问数据的操作,需要检查当前身份对该对象的权限。官方说明

本文不讨论如何猜 ID。测试直接使用自己创建的两个已知对象,目的是证明服务端授权边界。

范围从可信身份来,不从表单来

演示查询使用 WHERE tenant=? AND id=?。其中 tenant 应来自服务端已经验证的登录与组织上下文,不是把用户请求里的 tenant_id 原样塞进去。

如果真实账号属于多个组织,还需要验证当前组织选择及成员状态。管理员、共享、协作等特殊权限也要形成明确规则,不能假设一个 tenant 条件涵盖所有角色。

本例仅采用单一归属模型:T1只能读写自己的备注,T2亦然。查不到与不在范围内都返回相同的 LookupError,示例没有实现HTTP错误映射。

写入也要带同样范围

读取受限制,但更新仍然只按 ID,是另一种常见缺口。不能因为页面上看不到对方的编辑按钮,就认为后端已经拒绝修改。

因此更新语句也带 tenant 与 id 条件,并核对受影响行数。找不到目标则报错,事务中的修改不算成功。

读写以外,列表、导出、搜索、附件下载、缓存和后台任务都可能访问同一对象。这篇只检查两条函数,不把它包装成完成整个产品的安全评估。

完整本地演示

保存为 tenant_scope.py,运行 python tenant_scope.py。环境是 Python 3.12 和标准库 SQLite。代码中的 tenant_from_auth 是为说明边界的测试参数,本例没有真实登录实现。

python 复制代码
import json
import sqlite3


def get_note(db, tenant_from_auth, note_id):
    row = db.execute('SELECT body FROM notes WHERE tenant=? AND id=?',
                     (tenant_from_auth, note_id)).fetchone()
    if row is None:
        raise LookupError('not found')
    return row[0]


def update_note(db, tenant_from_auth, note_id, body):
    with db:
        count = db.execute('UPDATE notes SET body=? WHERE tenant=? AND id=?',
                           (body, tenant_from_auth, note_id)).rowcount
        if count != 1:
            raise LookupError('not found')


if __name__ == '__main__':
    db = sqlite3.connect(':memory:')
    db.executescript('''
        CREATE TABLE notes(id INTEGER PRIMARY KEY,tenant TEXT,body TEXT);
        INSERT INTO notes VALUES (1,'T1','A-private');
        INSERT INTO notes VALUES (2,'T2','B-private');
    ''')
    # Only local fixture data; this deliberately unsafe query is the baseline.
    unsafe = db.execute('SELECT body FROM notes WHERE id=2').fetchone()[0]
    own = get_note(db, 'T1', 1)
    denied = {}
    for name, action in {
        'cross_read': lambda: get_note(db, 'T1', 2),
        'cross_write': lambda: update_note(db, 'T1', 2, 'changed'),
        'missing_read': lambda: get_note(db, 'T1', 999),
    }.items():
        try:
            action()
            denied[name] = False
        except LookupError:
            denied[name] = True
    update_note(db, 'T1', 1, 'A-updated')
    final = db.execute('SELECT id,tenant,body FROM notes ORDER BY id').fetchall()
    assert unsafe == 'B-private' and own == 'A-private'
    assert all(denied.values())
    assert final == [(1, 'T1', 'A-updated'), (2, 'T2', 'B-private')]
    print(json.dumps(dict(unsafe_baseline=unsafe, own_read=own,
                         denied=denied, final_rows=final), indent=2))
    db.close()

实际结果:故意省略租户条件的基线能拿到 B-private;受限函数允许T1读写自己的记录,拒绝跨租户读、跨租户写和不存在ID;最终T2数据保持原样。

拒绝写入后还要读取真实存储,确认对方记录没有改变。仅检查函数抛了异常,不足以说明报错前没有发生写入。

从这两个函数往外查

我会沿同一业务对象找所有访问入口:详情与列表是否使用同一范围,批量操作是否逐项检查,附件是否继承归属,缓存键是否包含必要身份范围,后台工作是否仍知道是为哪个租户执行。

这些是后续检查建议,不是本篇已经跑过的项目测试。对真实系统执行检查前,需要明确授权范围和隔离数据,不能用外部用户记录做演示。

一个很小但清楚的验收表

场景 预期
自己的对象 在本例权限内可读写
别人的已知对象 ID 拒绝读写,不改变对方记录
不存在的 ID 返回约定的不可访问结果
自报另一个租户 不能改变服务端身份上下文

前三行已在本地函数层运行,第四行涉及认证与HTTP接入,本文没有实现,不能标为通过。

登录、对象归属、业务操作权限是不同检查。先把这几个条件落到真实访问入口,再讨论界面怎样提示,比在页面上加一个角色标签更接近实际隔离。

------ Linksass|AI SaaS 独立开发者

相关推荐
用户7624117567051 小时前
第十五章 数据分析
python·数据分析
2601_961963382 小时前
电子合同检索与全文搜索怎么测、怎么选:六个指标 + 五家平台对比 + 八个自测问题
数据库·后端·python·django·智能合约
SEO_juper2 小时前
hreflang 审计工具:外贸多语言站谷歌SEO的隐性杀手
开发语言·人工智能·爬虫·python·seo
打工仔折腾 AI2 小时前
从零开始大模型微调:基于 PyTorch 与 ChatGLM 的 LoRA 实践指南
人工智能·pytorch·后端·python·深度学习·性能优化·ai agent 实战
用户0332126663672 小时前
Python 自动化办公:如何从零快速生成结构清晰的 Excel 报表
python·excel
刘 大 望3 小时前
Agent 的规划与执行范式:Plan-Execute-Replan 与 ReAct
java·python·ai·chatgpt·langchain·ai编程
在世修行3 小时前
智学文枢:数据备份与恢复
python·数据安全·备份恢复·zipfile
ONEYAC唯样3 小时前
TE:不只看产品,更要看价值:下一代高压连接方案如何选择?
python·django
高洁013 小时前
具身智能感知系统底层原理
人工智能·python·深度学习·机器学习·transformer