Files
codex-app-server/cloud/auth.py
T
host f39ed3654e ci3: lint/mypy 第二阶段清理 + 覆盖率 76% -> 78%(门槛不动)
基底:#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 漏洞;试跑产物已删,
提交后工作树干净。
2026-11-03 02:48:54 +00:00

379 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""认证与权限模块(云端架构,首版)。
认证方式:首版只用 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
]