基底:#251 头 174b631(堆叠于 #242 feat-ci-expand 之上), 基线 origin/main aebc6238;本轨不改 #251 已定语义、不动 workflow 门槛、不删检查、不加 skip。 lint(ruff E,F@120):109 -> 10 - F401 43 -> 0:删未用导入;try/except 双绑定的名字出现 “打地鼠”(删 except 分支后 try 分支又被标出),两分支 均已核对正文无引用后清掉;MCP 三模块的 Error 名不删, 改走 importlib 属性绑定(见下),两路径绑定都保留。 - E702 40 -> 0:全在 host.py main() 的 argparse 段,纯分行, tokenize 按分号 token 拆(含 2377 行多行语句只断首个分号); 一次 5 空格缩进失误已回滚重做并以 ast + 全量 host 测试验证。 - E501 13 -> 0:host.py 5 项随 E702 分行连带清掉;mcp_server 3 项把描述拆成相邻字面量(AST 比对 24 条工具描述与原值 完全一致,注意 ruff 按东亚宽度计长,CJK 算 2);test_host 5 项断开 git commit 参数表。 - E402 8 -> 10:生产侧 2 项清掉(db.py _re、github_app _base64 移到顶部,之前无引用、无重名);剩余 10 项全是 tests/ 的 sys.path 前置 import(6 项旧 + 4 个新测试文件 沿用同一既定模式),移上去会破坏单文件直跑,有意保留。 - F841 4 -> 0:只去绑定、不删调用(run_task、DB 入队、 tarfile 上下文、git 提交),各有注释与原测试/新测试覆盖; host dispatch_from_branch 新增真实 git 仓库回归测试。 - F811 1 -> 0:server 本地 _row_get 与 peer_messages.row_get 逐行相同,删本地定义,全部调用点解析到同一 import; 新增 test_server_row_get(同一对象 + 三路行为)。 mypy(生产范围):24 -> 0,零新增 type: ignore - no-redef 9 -> 0:MCP 三模块 try/except 两次 from-import 改 importlib.import_module + 属性绑定(cloud 优先、flat copy 回落、内层 ImportError 同样回落,语义与原来一致; flat-copy 子进程测试证实回落后 __module__ 为顶层名); server _row_get 随 F811 消除。 - arg-type 10 -> 0:harness2_tasks shared 改 TypedDict 逐键精确类型;CodexProbeAdapter 补 no-op validate_tree (_adapter_validate 本就跳过缺失方法,行为一致)。 - var-annotated 3 -> 0:sub2api _SHARED_ADMIN/_login_slots、 verify-multiuser RESULTS 加精确注解。 - assignment/misc 2 -> 0:marker_tools ZoneInfo 先声明 Any 再 try import(草稿先验 mypy 通过)。 bandit:总数 286 不变,High 仍 4 项,全在 host.py,本轨不改 (属第 3 项白名单轨范围),只做 triage: - 1319 run_verify:无插值,整串验测命令,管道/&& 须 shell 语义(新增 test_host_run_verify 断言管道与 && 真实生效)。 - 1497 notify:模板来自算子配置,三处插值全部 shlex.quote, 证据为既有 test_notify_failure_quotes_shell_metacharacters (; touch 与 $(touch ) 不执行)。 - 2099/2103 goal verify:无插值,任务配置验测串,同 run_verify。 - 无新增 High;pip-audit 0 漏洞。 覆盖率(同口径三套件 coverage 计量):75.96% -> 77.87% (22392 行/缺 5385 -> 22437 行/缺 4963,显示 76% -> 78%) - client.py 0% -> 90%(pipe 假子进程 + main 分支) - run_parallel.py 0% -> 97%、migrate 包装 0% -> 95% - playwright_mcp 37% -> 62%、imagegen_mcp 47% -> 79%、 websearch_mcp 51% -> 86%、deploy.py 56% -> 77% - verify-multiuser 0% -> 27%(仅 check/req helper;main() 是主机侧真实多用户实弹验证,按门禁不建生产任务、 不以 mock 冒充,仍为缺口) - host.py 55% -> 57%(文件 2020 -> 2060 行系 E702 分行 的计量口径变化,非代码膨胀) - 未到 85%:门槛保持 75% 不动,不删低覆盖模块出统计口径; 缺口主力仍是 server.py / worker.py / host.py 大文件, 下一阶段须针对性深测,非本轨快赢可及。 验证:三套件 1576 -> 1629 项全过(tests 127、cloud/tests 1449 skipped=4、deploy/tests 53);ruff E9 通过;mypy 0; bandit High 4(同上);pip-audit 0 漏洞;试跑产物已删, 提交后工作树干净。
379 lines
14 KiB
Python
379 lines
14 KiB
Python
"""认证与权限模块(云端架构,首版)。
|
||
|
||
认证方式:首版只用 API key + 哈希。
|
||
|
||
- 创建用户时用 ``secrets.token_urlsafe(32)`` 生成明文 key,仅返回给调用方一次;
|
||
库里只存 ``hashlib.sha256(api_key_plain.encode()).hexdigest()``,绝不存明文。
|
||
- 认证时对输入做同样的 sha256 再查库比对。
|
||
|
||
OAuth(GitHub/Google)是下一步接线:本模块不提供任何 OAuth 流程、
|
||
不编造假 OAuth 端点或假 token 校验,接线时再由控制面按真实
|
||
OAuth 协议接入,本模块的 API key 认证保持独立可用。
|
||
|
||
权限模型:项目内角色 viewer < contributor < maintainer < owner。
|
||
动作最低角色:read -> viewer,create_task -> contributor,
|
||
approve_merge -> maintainer,manage_project -> owner(仅 owner)。
|
||
未加入项目一律无权限。
|
||
|
||
审计:audit() 只追加,list_audit() 只读;本模块不提供删除或修改
|
||
审计记录的方法。
|
||
|
||
零第三方依赖,只用 Python 标准库。
|
||
"""
|
||
|
||
import hashlib
|
||
import secrets
|
||
from cloud.db import connect as _db_connect
|
||
import time
|
||
import uuid
|
||
|
||
ROLES = ["viewer", "contributor", "maintainer", "owner"]
|
||
|
||
_ROLE_LEVEL = {role: i for i, role in enumerate(ROLES)}
|
||
|
||
_ACTION_MIN_ROLE = {
|
||
"read": "viewer",
|
||
"create_task": "contributor",
|
||
"approve_merge": "maintainer",
|
||
"manage_project": "owner",
|
||
}
|
||
|
||
|
||
class AuthStore:
|
||
"""基于 SQLite 的用户 / 项目成员 / 审计存储。"""
|
||
|
||
def __init__(self, db_path):
|
||
self.db_path = str(db_path)
|
||
self.conn = _db_connect(self.db_path)
|
||
self.conn.execute(
|
||
"CREATE TABLE IF NOT EXISTS users("
|
||
"id TEXT PRIMARY KEY, "
|
||
"username TEXT UNIQUE, "
|
||
"api_key_hash TEXT, "
|
||
"created_at REAL)"
|
||
)
|
||
self.conn.execute(
|
||
"CREATE TABLE IF NOT EXISTS project_members("
|
||
"project_id TEXT, "
|
||
"user_id TEXT, "
|
||
"role TEXT, "
|
||
"PRIMARY KEY(project_id, user_id))"
|
||
)
|
||
self.conn.execute(
|
||
"CREATE TABLE IF NOT EXISTS audit_log("
|
||
"id INTEGER PRIMARY KEY AUTOINCREMENT, "
|
||
"user_id TEXT, "
|
||
"project_id TEXT, "
|
||
"action TEXT, "
|
||
"detail TEXT, "
|
||
"created_at REAL)"
|
||
)
|
||
self.conn.commit()
|
||
|
||
def create_user(self, username):
|
||
"""创建用户,返回 (user_id, api_key_plain),明文仅返回这一次。"""
|
||
user_id = uuid.uuid4().hex
|
||
api_key_plain = secrets.token_urlsafe(32)
|
||
api_key_hash = hashlib.sha256(api_key_plain.encode()).hexdigest()
|
||
self.conn.execute(
|
||
"INSERT INTO users(id, username, api_key_hash, created_at)"
|
||
" VALUES(?, ?, ?, ?)",
|
||
(user_id, username, api_key_hash, time.time()),
|
||
)
|
||
self.conn.commit()
|
||
return user_id, api_key_plain
|
||
|
||
def rotate_api_key(self, user_id):
|
||
"""轮换用户的 primary key,返回新明文 key(只返回这一次)。
|
||
|
||
旧 primary key 的哈希即被替换,轮换后旧 primary 立即失效;
|
||
附加 key(api_keys 表)不受影响。用户不存在时抛 ValueError
|
||
(调用方不得用它凭空建用户)。
|
||
|
||
注意:OAuth 登录不再调本方法(登录改为签发附加 key,旧 key
|
||
不失效,见 create_api_key);本方法只留给“明确要轮换
|
||
primary”的显式操作。
|
||
"""
|
||
row = self.conn.execute(
|
||
"SELECT id FROM users WHERE id = ?", (user_id,)
|
||
).fetchone()
|
||
if row is None:
|
||
raise ValueError("unknown user: %r" % (user_id,))
|
||
api_key_plain = secrets.token_urlsafe(32)
|
||
api_key_hash = hashlib.sha256(api_key_plain.encode()).hexdigest()
|
||
self.conn.execute(
|
||
"UPDATE users SET api_key_hash = ? WHERE id = ?",
|
||
(api_key_hash, user_id),
|
||
)
|
||
self.conn.commit()
|
||
return api_key_plain
|
||
|
||
def _ensure_api_keys_table(self):
|
||
"""建附加 key 表(幂等):一行一个 key,只存哈希 + 后 4 位
|
||
提示,绝不存明文;revoked_at 非空即已撤销,认证时跳过。"""
|
||
self.conn.execute(
|
||
"CREATE TABLE IF NOT EXISTS api_keys("
|
||
"id TEXT PRIMARY KEY, "
|
||
"user_id TEXT, "
|
||
"key_hash TEXT UNIQUE, "
|
||
"label TEXT, "
|
||
"hint TEXT, "
|
||
"created_at REAL, "
|
||
"last_used_at REAL, "
|
||
"revoked_at REAL)"
|
||
)
|
||
self.conn.commit()
|
||
|
||
def create_api_key(self, user_id, label):
|
||
"""给用户签发一个附加 key,返回 (key_id, 明文 key)。
|
||
|
||
明文只返回这一次;库里只存哈希与后 4 位提示。附加 key 与
|
||
primary key(users.api_key_hash)互相独立:撤销、轮换一
|
||
方不影响另一方。用户不存在时抛 ValueError。
|
||
"""
|
||
row = self.conn.execute(
|
||
"SELECT id FROM users WHERE id = ?", (user_id,)
|
||
).fetchone()
|
||
if row is None:
|
||
raise ValueError("unknown user: %r" % (user_id,))
|
||
self._ensure_api_keys_table()
|
||
for _ in range(5):
|
||
api_key_plain = secrets.token_urlsafe(32)
|
||
api_key_hash = hashlib.sha256(
|
||
api_key_plain.encode()).hexdigest()
|
||
exists = self.conn.execute(
|
||
"SELECT id FROM api_keys WHERE key_hash = ?",
|
||
(api_key_hash,),
|
||
).fetchone()
|
||
if exists is None:
|
||
break
|
||
else:
|
||
raise RuntimeError("failed to generate a unique api key")
|
||
key_id = secrets.token_hex(8)
|
||
self.conn.execute(
|
||
"INSERT INTO api_keys(id, user_id, key_hash, label, hint,"
|
||
" created_at, last_used_at, revoked_at)"
|
||
" VALUES(?, ?, ?, ?, ?, ?, NULL, NULL)",
|
||
(key_id, user_id, api_key_hash, label,
|
||
api_key_plain[-4:], time.time()),
|
||
)
|
||
self.conn.commit()
|
||
return key_id, api_key_plain
|
||
|
||
def authenticate(self, api_key):
|
||
"""用 API key 认证,成功返回 user_id,失败返回 None。
|
||
|
||
先查 primary(users.api_key_hash),再查附加 key(api_keys
|
||
表,未撤销的才算)。附加 key 认证成功时顺手更新
|
||
last_used_at,但每小时最多写一次(认证是热路径,不为
|
||
每次请求都写库)。
|
||
"""
|
||
if not api_key:
|
||
return None
|
||
api_key_hash = hashlib.sha256(api_key.encode()).hexdigest()
|
||
row = self.conn.execute(
|
||
"SELECT id FROM users WHERE api_key_hash = ?", (api_key_hash,)
|
||
).fetchone()
|
||
if row:
|
||
return row[0]
|
||
self._ensure_api_keys_table()
|
||
row = self.conn.execute(
|
||
"SELECT id, user_id, last_used_at FROM api_keys"
|
||
" WHERE key_hash = ? AND revoked_at IS NULL",
|
||
(api_key_hash,),
|
||
).fetchone()
|
||
if row is None:
|
||
return None
|
||
now = time.time()
|
||
if row[2] is None or now - row[2] >= 3600:
|
||
self.conn.execute(
|
||
"UPDATE api_keys SET last_used_at = ? WHERE id = ?",
|
||
(now, row[0]),
|
||
)
|
||
self.conn.commit()
|
||
return row[1]
|
||
|
||
def list_api_keys(self, user_id):
|
||
"""列出用户全部 key(只看自己的,调用方须先认证)。
|
||
|
||
返回 dict 列表:primary(id 为 "legacy",即建用户时发的
|
||
key,没有 hint)+ 附加 key(按创建时间排序);已撤销的也
|
||
列出(revoked_at 非空),调用方按此判断。绝不返回明文、
|
||
不返回哈希。
|
||
"""
|
||
self._ensure_api_keys_table()
|
||
keys = []
|
||
row = self.conn.execute(
|
||
"SELECT api_key_hash, created_at FROM users WHERE id = ?",
|
||
(user_id,),
|
||
).fetchone()
|
||
if row is not None and row[0]:
|
||
keys.append({"id": "legacy", "label": "primary", "hint": None,
|
||
"created_at": row[1], "last_used_at": None,
|
||
"revoked_at": None})
|
||
for r in self.conn.execute(
|
||
"SELECT id, label, hint, created_at, last_used_at,"
|
||
" revoked_at FROM api_keys WHERE user_id = ?"
|
||
" ORDER BY created_at",
|
||
(user_id,)).fetchall():
|
||
keys.append({"id": r[0], "label": r[1], "hint": r[2],
|
||
"created_at": r[3], "last_used_at": r[4],
|
||
"revoked_at": r[5]})
|
||
return keys
|
||
|
||
def revoke_api_key(self, user_id, key_id):
|
||
"""撤销一个 key,成功返回 True,无此 key 或已撤销返回 False。
|
||
|
||
key_id 为 "legacy" 时清 primary(users.api_key_hash 置空);
|
||
其它 id 撤销附加 key 行(revoked_at 置当前时间,不删行,
|
||
审计与列表仍可见)。撤销自己的最后一个 key 后须重新登录
|
||
拿新 key,调用方应把这点写进响应提示。
|
||
"""
|
||
self._ensure_api_keys_table()
|
||
if key_id == "legacy":
|
||
cur = self.conn.execute(
|
||
"UPDATE users SET api_key_hash = NULL"
|
||
" WHERE id = ? AND api_key_hash IS NOT NULL",
|
||
(user_id,),
|
||
)
|
||
self.conn.commit()
|
||
return cur.rowcount > 0
|
||
cur = self.conn.execute(
|
||
"UPDATE api_keys SET revoked_at = ?"
|
||
" WHERE id = ? AND user_id = ? AND revoked_at IS NULL",
|
||
(time.time(), key_id, user_id),
|
||
)
|
||
self.conn.commit()
|
||
return cur.rowcount > 0
|
||
|
||
def revoke_other_api_keys(self, user_id, keep_hash):
|
||
"""撤销除 keep_hash 之外的全部 key(附加 + primary),
|
||
返回撤销数。用于“这台设备之外的 key 全作废”:调用方传
|
||
本次请求 key 的哈希(即保留的那一个),其余一律撤销。"""
|
||
self._ensure_api_keys_table()
|
||
cur = self.conn.execute(
|
||
"UPDATE api_keys SET revoked_at = ?"
|
||
" WHERE user_id = ? AND key_hash != ?"
|
||
" AND revoked_at IS NULL",
|
||
(time.time(), user_id, keep_hash),
|
||
)
|
||
count = cur.rowcount
|
||
cur = self.conn.execute(
|
||
"UPDATE users SET api_key_hash = NULL"
|
||
" WHERE id = ? AND api_key_hash IS NOT NULL"
|
||
" AND api_key_hash != ?",
|
||
(user_id, keep_hash),
|
||
)
|
||
count += cur.rowcount
|
||
self.conn.commit()
|
||
return count
|
||
|
||
def create_project(self, project_id, owner_user_id):
|
||
"""创建项目成员记录:指定用户为该项目的 owner。"""
|
||
self.conn.execute(
|
||
"INSERT OR REPLACE INTO project_members(project_id, user_id, role)"
|
||
" VALUES(?, ?, 'owner')",
|
||
(project_id, owner_user_id),
|
||
)
|
||
self.conn.commit()
|
||
|
||
def _member_role(self, project_id, user_id):
|
||
row = self.conn.execute(
|
||
"SELECT role FROM project_members"
|
||
" WHERE project_id = ? AND user_id = ?",
|
||
(project_id, user_id),
|
||
).fetchone()
|
||
return row[0] if row else None
|
||
|
||
def set_member(self, project_id, actor_user_id, target_user_id, role):
|
||
"""改成员角色:只有 owner 能改,否则 raise PermissionError;同时记审计。"""
|
||
if role not in ROLES:
|
||
raise ValueError("unknown role: %r" % (role,))
|
||
if self._member_role(project_id, actor_user_id) != "owner":
|
||
raise PermissionError(
|
||
"only project owner can change members"
|
||
)
|
||
self.conn.execute(
|
||
"INSERT OR REPLACE INTO project_members(project_id, user_id, role)"
|
||
" VALUES(?, ?, ?)",
|
||
(project_id, target_user_id, role),
|
||
)
|
||
self.conn.commit()
|
||
self.audit(
|
||
actor_user_id,
|
||
project_id,
|
||
"set_member",
|
||
"%s:%s" % (target_user_id, role),
|
||
)
|
||
|
||
def remove_member(self, project_id, actor_user_id, target_user_id):
|
||
"""删成员:只有 owner 能删,否则 raise PermissionError;同时记审计。"""
|
||
if self._member_role(project_id, actor_user_id) != "owner":
|
||
raise PermissionError(
|
||
"only project owner can change members"
|
||
)
|
||
self.conn.execute(
|
||
"DELETE FROM project_members"
|
||
" WHERE project_id = ? AND user_id = ?",
|
||
(project_id, target_user_id),
|
||
)
|
||
self.conn.commit()
|
||
self.audit(
|
||
actor_user_id,
|
||
project_id,
|
||
"remove_member",
|
||
target_user_id,
|
||
)
|
||
|
||
def check_permission(self, user_id, project_id, action):
|
||
"""检查权限:满足最低角色返回 True,否则 False(含未加入项目)。"""
|
||
min_role = _ACTION_MIN_ROLE.get(action)
|
||
if min_role is None:
|
||
return False
|
||
role = self._member_role(project_id, user_id)
|
||
if role is None:
|
||
return False
|
||
# 未知角色(如直接写库留下的 'member')一律按无权限处理,
|
||
# 绝不能 KeyError 冒泡成 500:权限检查的失败语义只能是
|
||
# False / PermissionError,绝不是服务端异常。
|
||
level = _ROLE_LEVEL.get(role)
|
||
if level is None:
|
||
return False
|
||
return level >= _ROLE_LEVEL[min_role]
|
||
|
||
def require_permission(self, user_id, project_id, action):
|
||
"""不满足权限时 raise PermissionError。"""
|
||
if not self.check_permission(user_id, project_id, action):
|
||
raise PermissionError(
|
||
"user %s lacks %s on project %s" % (user_id, action, project_id)
|
||
)
|
||
|
||
def audit(self, user_id, project_id, action, detail=""):
|
||
"""只追加一条审计记录。"""
|
||
self.conn.execute(
|
||
"INSERT INTO audit_log(user_id, project_id, action, detail, created_at)"
|
||
" VALUES(?, ?, ?, ?, ?)",
|
||
(user_id, project_id, action, detail, time.time()),
|
||
)
|
||
self.conn.commit()
|
||
|
||
def list_audit(self, project_id, limit=100):
|
||
"""按 created_at 倒序返回该项目的审计列表(dict)。"""
|
||
rows = self.conn.execute(
|
||
"SELECT id, user_id, project_id, action, detail, created_at"
|
||
" FROM audit_log WHERE project_id = ?"
|
||
" ORDER BY created_at DESC, id DESC LIMIT ?",
|
||
(project_id, limit),
|
||
).fetchall()
|
||
return [
|
||
{
|
||
"id": row[0],
|
||
"user_id": row[1],
|
||
"project_id": row[2],
|
||
"action": row[3],
|
||
"detail": row[4],
|
||
"created_at": row[5],
|
||
}
|
||
for row in rows
|
||
]
|