"""认证与权限模块(云端架构,首版)。 认证方式:首版只用 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 from cloud.ledger import append_entry as _ledger_append from cloud.ledger import ensure_schema as _ledger_ensure 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", } # audit_log 建表 SQL(AuthStore.__init__ 与 audit_on_conn 共用同一 # 份,绝不能各写一份后漂移)。PG 适配层把其中的 # INTEGER PRIMARY KEY AUTOINCREMENT 翻译成 identity 列,见 # cloud/db.py;SQLite 下原样执行。 _AUDIT_LOG_TABLE_SQL = ( "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)" ) def audit_on_conn(conn, user_id, project_id, action, detail="", created_at=None): """在调用方给定的连接上写一对审计记录(单次尝试)。 与 AuthStore.audit() 是同一对写:audit_log 行 + audit_ledger 链条目同 created_at(调用方不给时取当前时间),先建两张表 (幂等),再写两行。本函数不 commit、不 rollback、不重试: 事务边界属于调用方——调用方须在自己的事务里 commit,失败 时 rollback 整段业务写、链头唯一冲突时 rollback 后重试整段 (只重试本对写会丢掉调用方同事务里已写但未提交的业务行; 见 cloud/ledger.py append_entry 的警告)。AuthStore.audit() 即本函数 + “自有连接上 commit、唯一冲突重试 5 次”的包装。 存在理由:automations.run_due_automation 拿到的是调用方连接 (任务 INSERT 与产出记录都在该连接的事务里),不能像服务端 handler 那样另开 AuthStore(自有连接、自有事务)去写审计—— 那样审计与任务不在同一事务,审计失败时任务已落盘,所谓 “整对回滚”只保审计对、不保业务对。本函数让自动化执行器 的审计行与任务行、产出记录行同事务提交、同事务回滚。 """ if created_at is None: created_at = time.time() created_at = float(created_at) conn.execute(_AUDIT_LOG_TABLE_SQL) _ledger_ensure(conn) conn.execute( "INSERT INTO audit_log(user_id, project_id, action," " detail, created_at)" " VALUES(?, ?, ?, ?, ?)", (user_id, project_id, action, detail, created_at), ) _ledger_append(conn, user_id, project_id, action, detail=detail, created_at=created_at) 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(_AUDIT_LOG_TABLE_SQL) # 防篡改账本(哈希链):audit_log 本身不动,链写进 # audit_ledger;建表失败即建库失败,不静默丢链。 _ledger_ensure(self.conn) 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=""): """只追加一条审计记录,并同步写哈希链(同事务)。 audit_log 行与 audit_ledger 链条目同 created_at、同事务 提交:链写失败时整对回滚并抛错(fail-closed,绝不留 “audit_log 有行、链上无行”的静默缺口)。并发抢链头 导致唯一冲突时在事务边界重试整对写入,最多 5 次; 其它错误直接回滚抛错,不重试。 实现即 audit_on_conn() + 本连接上的 commit/重试包装, 对写本身与 #241 完全一致(同一函数、同一 created_at 语义),不是第二套审计写路径。 """ now = time.time() last_exc = None for _attempt in range(5): try: audit_on_conn(self.conn, user_id, project_id, action, detail=detail, created_at=now) self.conn.commit() return except Exception as exc: last_exc = exc try: self.conn.rollback() except Exception: pass if not isinstance(exc, sqlite3.IntegrityError) \ and type(exc).__name__ != "PostgresIntegrityError": raise raise last_exc 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 ]