Files
codex-app-server/cloud/auth.py
T

179 lines
6.4 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
import sqlite3
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 = sqlite3.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 authenticate(self, api_key):
"""用 API key 认证,成功返回 user_id,失败返回 None。"""
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()
return row[0] if row else None
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 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
return _ROLE_LEVEL[role] >= _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
]