Files
kefu/wechat_rpa/group_authorization.py
T
2026-11-04 10:14:23 +08:00

361 lines
20 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.
"""Machine-only edits of existing Group bindings in KF's native role model.
The caller authenticates the Group owner; the signed actor ID is its assertion,
not a KF user ID. SQLite serializes revisions, private roles, assignment and audit.
Neither this module nor its HTTP routes enroll users or accept human sessions.
"""
from __future__ import annotations
import hashlib
import hmac
import json
import secrets
import time
from typing import Any, Callable
import group_access
import rbac
OPTIONS_PATH = "/group/access-options"
UPDATE_PATH = "/group/access-update"
MAX_REQUEST_BYTES = 256 * 1024
MAX_RESPONSE_BYTES = group_access.MAX_RESPONSE_BYTES
MAX_PERMISSIONS = group_access.MAX_PERMISSIONS
MAX_NONCES = group_access.MAX_NONCES
WINDOW_SECONDS = group_access.WINDOW_SECONDS
_POSITIVE_MAX = 9_223_372_036_854_775_807
_BASE_FIELDS = {"client_id", "gid", "source_admin_id", "company_id"}
_WRITE_FIELDS = {"actor_source_admin_id", "request_id", "expected_revision", "role_code", "permissions"}
_SCHEMA = (
"""CREATE TABLE IF NOT EXISTS group_authorization_nonces (
nonce TEXT PRIMARY KEY, expires_at INTEGER NOT NULL)""",
"""CREATE INDEX IF NOT EXISTS idx_group_authorization_nonce_expiry
ON group_authorization_nonces(expires_at)""",
"""CREATE TABLE IF NOT EXISTS group_authorization_requests (
request_id TEXT PRIMARY KEY, body_digest TEXT NOT NULL,
result_json BLOB NOT NULL, created_at INTEGER NOT NULL)""",
"""CREATE TABLE IF NOT EXISTS group_authorization_private_roles (
role_code TEXT PRIMARY KEY REFERENCES roles(code) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
permissions_digest TEXT NOT NULL, created_at INTEGER NOT NULL)""",
"""CREATE INDEX IF NOT EXISTS idx_group_authorization_private_owner
ON group_authorization_private_roles(user_id,permissions_digest)""",
"""CREATE TABLE IF NOT EXISTS group_authorization_audit (
request_id TEXT PRIMARY KEY, actor_source_admin_id INTEGER NOT NULL,
target_user_id INTEGER NOT NULL, detail_json TEXT NOT NULL,
created_at INTEGER NOT NULL)""",
)
class AuthorizationError(group_access.AccessError):
pass
def _unavailable() -> AuthorizationError:
return AuthorizationError(503, "客服授权服务暂不可用")
def request_signature(key: bytes, body: bytes, timestamp: str, nonce: str, path: str) -> str:
if path not in {OPTIONS_PATH, UPDATE_PATH}:
raise ValueError("Unsupported authorization path")
canonical = f"POST\n{path}\n{timestamp}\n{nonce}\n{hashlib.sha256(body).hexdigest()}"
return hmac.new(key, canonical.encode("ascii"), hashlib.sha256).hexdigest()
def _identifier(value: Any) -> bool:
return type(value) is int and 0 < value <= _POSITIVE_MAX
def _code(value: Any) -> bool:
return (isinstance(value, str) and 0 < len(value) <= 256
and all(ord(char) >= 32 and ord(char) != 127
and not 0xD800 <= ord(char) <= 0xDFFF for char in value))
def parse_request(body: bytes, path: str) -> dict[str, Any]:
"""Validate exact types/fields without normalizing identity or permission codes."""
try:
def invalid_constant(_value):
raise ValueError("Invalid JSON constant")
value = json.loads(body.decode("utf-8"), object_pairs_hook=group_access._object,
parse_constant=invalid_constant)
fields = _BASE_FIELDS | (_WRITE_FIELDS if path == UPDATE_PATH else set())
if not isinstance(value, dict) or set(value) != fields:
raise ValueError("Invalid fields")
if (value["client_id"] != "kefu" or not isinstance(value["gid"], str)
or not group_access._GID.fullmatch(value["gid"])
or not _identifier(value["source_admin_id"]) or not _identifier(value["company_id"])):
raise ValueError("Invalid subject")
if path == UPDATE_PATH:
if (not _identifier(value["actor_source_admin_id"])
or not isinstance(value["request_id"], str)
or not group_access._HEX_32.fullmatch(value["request_id"])
or not isinstance(value["expected_revision"], str)
or not group_access._HEX_64.fullmatch(value["expected_revision"])
or not _code(value["role_code"])):
raise ValueError("Invalid mutation")
permissions = value["permissions"]
if permissions is not None and (not isinstance(permissions, list)
or len(permissions) > MAX_PERMISSIONS
or any(not _code(code) for code in permissions)
or len(set(permissions)) != len(permissions)):
raise ValueError("Invalid permissions")
return value
except (ValueError, UnicodeError, RecursionError):
raise AuthorizationError(422, "客服授权请求无效") from None
def _encode(value: Any) -> bytes:
raw = json.dumps(value, ensure_ascii=False, separators=(",", ":"), allow_nan=False).encode("utf-8")
if len(raw) > MAX_RESPONSE_BYTES:
raise _unavailable()
return raw
def _binding(db, request: dict[str, Any]):
row = db.execute("""SELECT b.provider,b.gid,b.source_admin_id,b.company_id,
b.source_account,b.source_name,u.id,u.username,u.active,u.role AS role_code,
r.name AS role_name FROM group_sso_bindings b JOIN users u ON u.id=b.user_id
JOIN roles r ON r.code=u.role WHERE b.provider='zyt_group' AND b.gid=?""",
(request["gid"],)).fetchone()
if row is None:
raise AuthorizationError(404, "客服绑定用户不存在")
if (row["source_admin_id"] != request["source_admin_id"]
or row["company_id"] != request["company_id"]):
raise AuthorizationError(409, "客服绑定身份已变化,请重新加载")
return row
def _assigned(db, role: str) -> list[str]:
# Exact native authorization semantics: enabled is navigation metadata only.
result = sorted(rbac.permissions_of(db, role))
if len(result) > MAX_PERMISSIONS or any(not _code(code) for code in result):
raise _unavailable()
return result
def _revision(db, row) -> str:
identity = {name: row[name] for name in ("provider", "gid", "source_admin_id", "company_id",
"id", "username", "source_account", "source_name", "role_code")}
identity["active"] = bool(row["active"])
identity["permissions"] = _assigned(db, row["role_code"])
return hashlib.sha256(_encode(identity)).hexdigest()
def _result(db, row, now: int) -> dict[str, Any]:
definitions = rbac.catalog(db)
if len(definitions) > MAX_PERMISSIONS:
raise _unavailable()
catalog = []
known = set()
for item in definitions:
if (not _code(item["code"]) or item["code"] in known
or item["kind"] not in {"page", "action", "link"}
or any(not isinstance(item[name], str) for name in ("name", "group_name", "page_name", "source"))):
raise _unavailable()
known.add(item["code"])
catalog.append({"code": item["code"], "name": item["name"], "group": item["group_name"],
"page": item["page_name"], "kind": "page" if item["kind"] == "link" else item["kind"],
"source": item["source"], "enabled": bool(item["enabled"]), "sort": int(item["sort_order"])})
roles = []
for role in db.execute("""SELECT r.code,r.name FROM roles r
LEFT JOIN group_authorization_private_roles p ON p.role_code=r.code
WHERE p.role_code IS NULL OR p.user_id=? OR r.code=?
ORDER BY r.builtin DESC,r.code""", (row["id"], row["role_code"])):
assigned = _assigned(db, role["code"])
if not set(assigned) <= known or not _code(role["code"]) or not isinstance(role["name"], str):
raise _unavailable()
roles.append({"code": role["code"], "name": role["name"], "permissions": assigned})
assigned = _assigned(db, row["role_code"])
if not any(role["code"] == row["role_code"] for role in roles):
raise _unavailable()
subject = {"gid": row["gid"], "found": True, "source_admin_id": row["source_admin_id"],
"company_id": row["company_id"], "user": {
"id": row["id"], "username": row["username"], "source_account": row["source_account"],
"source_name": row["source_name"], "active": bool(row["active"]),
"role": {"code": row["role_code"], "name": row["role_name"]},
"permissions": assigned if row["active"] else []}}
return {"version": 1, "client_id": "kefu", "generated_at": now, "subject": subject,
"revision": _revision(db, row), "roles": roles, "catalog": {"permissions": catalog}}
class GroupAuthorization:
def __init__(self, database, clock: Callable[[], float] | None = None,
max_nonces: int = MAX_NONCES):
self.database = database
self.clock = clock or time.time
self.max_nonces = max_nonces
with database.connect() as db:
db.execute("BEGIN IMMEDIATE")
for statement in _SCHEMA:
db.execute(statement)
db.commit()
def _authenticate(self, body: bytes, headers, path: str, now: int) -> tuple[bytes, str]:
key = group_access.load_key()
timestamp = headers.get("x-group-timestamp", "")
nonce = headers.get("x-group-nonce", "")
signature = headers.get("x-group-signature", "")
if (not group_access._TIMESTAMP.fullmatch(timestamp)
or not group_access._HEX_32.fullmatch(nonce)
or not group_access._HEX_64.fullmatch(signature)
or abs(int(timestamp) - now) > WINDOW_SECONDS
or not hmac.compare_digest(request_signature(key, body, timestamp, nonce, path), signature)):
raise AuthorizationError(401, "集团服务认证无效")
return key, nonce
def _reserve_nonce(self, nonce: str, expires: int, now: int) -> None:
# A separate durable transaction consumes even requests whose domain
# transaction rolls back. Multiple instances/workers share this store.
with self.database.connect() as db:
db.execute("BEGIN IMMEDIATE")
db.execute("DELETE FROM group_authorization_nonces WHERE expires_at < ?", (now,))
if db.execute("SELECT 1 FROM group_authorization_nonces WHERE nonce=?", (nonce,)).fetchone():
raise AuthorizationError(401, "集团服务认证无效")
if db.execute("SELECT COUNT(*) FROM group_authorization_nonces").fetchone()[0] >= self.max_nonces:
raise _unavailable()
db.execute("INSERT INTO group_authorization_nonces(nonce,expires_at) VALUES (?,?)", (nonce, expires))
db.commit()
def _private_role(self, db, user_id: int, permissions: list[str], now: int) -> str:
granted = sorted(permissions)
digest = hashlib.sha256(_encode(granted)).hexdigest()
for row in db.execute("""SELECT p.role_code FROM group_authorization_private_roles p
JOIN roles r ON r.code=p.role_code WHERE p.user_id=? AND p.permissions_digest=?
AND NOT EXISTS(SELECT 1 FROM users u WHERE u.role=p.role_code AND u.id!=?)
ORDER BY p.role_code""", (user_id, digest, user_id)):
# Native KF admins can edit roles: never trust only the stored hash.
if _assigned(db, row["role_code"]) == granted:
return row["role_code"]
code = f"zyt_group_u{user_id}_{digest[:32]}"
while db.execute("SELECT 1 FROM roles WHERE code=?", (code,)).fetchone():
code = f"zyt_group_u{user_id}_{digest[:32]}_{secrets.token_hex(4)}"
import admin_backend
stamp = admin_backend.now_text()
db.execute("""INSERT INTO roles(code,name,builtin,created_at,updated_at)
VALUES (?,?,0,?,?)""", (code, f"集团个人授权(用户 {user_id})", stamp, stamp))
# Use the native grant validator but only ever on a newly-created role.
rbac.grant(db, code, granted, stamp)
db.execute("""INSERT INTO group_authorization_private_roles
(role_code,user_id,permissions_digest,created_at) VALUES (?,?,?,?)""", (code, user_id, digest, now))
return code
def _process(self, body: bytes, request: dict[str, Any], path: str, now: int) -> bytes:
with self.database.connect() as db:
db.execute("BEGIN IMMEDIATE" if path == UPDATE_PATH else "BEGIN")
row = _binding(db, request)
if path == OPTIONS_PATH:
return _encode(_result(db, row, now))
body_digest = hashlib.sha256(body).hexdigest()
previous = db.execute("SELECT body_digest,result_json FROM group_authorization_requests WHERE request_id=?",
(request["request_id"],)).fetchone()
if previous is not None:
if not hmac.compare_digest(previous["body_digest"], body_digest):
raise AuthorizationError(409, "客服授权请求编号已使用,请重新提交")
cached = bytes(previous["result_json"])
if len(cached) > MAX_RESPONSE_BYTES:
raise _unavailable()
if json.loads(cached)["subject"]["user"]["id"] != row["id"]:
raise AuthorizationError(409, "客服绑定身份已变化,请重新加载")
return cached
before_revision = _revision(db, row)
if not hmac.compare_digest(before_revision, request["expected_revision"]):
raise AuthorizationError(409, "客服授权已变化,请重新加载后保存")
selected = db.execute("SELECT code FROM roles WHERE code=?", (request["role_code"],)).fetchone()
private = db.execute("SELECT user_id FROM group_authorization_private_roles WHERE role_code=?",
(request["role_code"],)).fetchone()
if selected is None or (private is not None and private["user_id"] != row["id"]):
raise AuthorizationError(422, "客服角色无效,请重新加载")
permissions = request["permissions"]
if permissions is not None:
known = {item[0] for item in db.execute("SELECT code FROM permissions")}
if len(known) > MAX_PERMISSIONS:
raise _unavailable()
if not set(permissions) <= known:
raise AuthorizationError(422, "客服权限无效,请重新加载")
# Preserve the existing KF invariant: one active native admin role.
# Customizing admin creates an independent role and is a demotion.
demotes_admin = permissions is not None or request["role_code"] != "admin"
if row["active"] and row["role_code"] == "admin" and demotes_admin:
count = db.execute("SELECT COUNT(*) FROM users WHERE role='admin' AND active=1").fetchone()[0]
if count <= 1:
raise AuthorizationError(409, "系统至少需要保留一个启用的管理员")
role_code = (request["role_code"] if permissions is None else
self._private_role(db, row["id"], permissions, now))
import admin_backend
db.execute("UPDATE users SET role=?,updated_at=? WHERE id=?",
(role_code, admin_backend.now_text(), row["id"]))
updated = _binding(db, request)
result = _result(db, updated, now)
raw = _encode(result) # Bound the full DTO before anything commits.
detail = {"provider": "zyt_group", "gid": request["gid"],
"source_admin_id": request["source_admin_id"], "company_id": request["company_id"],
"actor_source_admin_id": request["actor_source_admin_id"], "target_user_id": row["id"],
"request_id": request["request_id"], "before_role": row["role_code"], "after_role": role_code,
"before_permissions": _assigned(db, row["role_code"]),
"after_permissions": _assigned(db, role_code),
"before_revision": before_revision, "after_revision": result["revision"]}
db.execute("""INSERT INTO group_authorization_audit
(request_id,actor_source_admin_id,target_user_id,detail_json,created_at) VALUES (?,?,?,?,?)""",
(request["request_id"], request["actor_source_admin_id"], row["id"],
_encode(detail).decode("utf-8"), now))
# Group actor IDs belong to another identity domain; do not forge a
# local actor FK. The full transactional audit above is authoritative.
self.database._audit(db, None, "group.authorization.update", json.dumps({
"request_id": request["request_id"], "actor_source_admin_id": request["actor_source_admin_id"],
"gid": request["gid"], "source_admin_id": request["source_admin_id"],
"company_id": request["company_id"], "target_user_id": row["id"]}, separators=(",", ":")), "")
db.execute("""INSERT INTO group_authorization_requests
(request_id,body_digest,result_json,created_at) VALUES (?,?,?,?)""",
(request["request_id"], body_digest, raw, now))
db.commit()
return raw
def respond(self, body: bytes, headers, path: str, *, query: bool = False) -> tuple[int, bytes, dict[str, str]]:
if path not in {OPTIONS_PATH, UPDATE_PATH}:
raise AuthorizationError(401, "集团服务认证无效")
if len(body) > MAX_REQUEST_BYTES:
raise AuthorizationError(413, "客服授权请求过大")
now = int(self.clock())
key, nonce = self._authenticate(body, headers, path, now)
try:
self._reserve_nonce(nonce, int(headers["x-group-timestamp"]) + WINDOW_SECONDS, now)
if query:
raise AuthorizationError(422, "客服授权请求无效")
request = parse_request(body, path)
raw = self._process(body, request, path, now)
status = 200
except group_access.AccessError as exc:
if exc.status == 401:
raise
status = exc.status
raw = _encode({"version": 1, "client_id": "kefu", "generated_at": now,
"error": {"code": status, "message": str(exc)}})
except Exception:
status = 503
raw = _encode({"version": 1, "client_id": "kefu", "generated_at": now,
"error": {"code": status, "message": "客服授权服务暂不可用"}})
return status, raw, {"Cache-Control": "no-store",
"X-Group-Response-Signature": group_access.response_signature(key, raw, nonce)}
async def handle_request(request, service: GroupAuthorization, path: str):
from fastapi.responses import JSONResponse, Response
try:
for name in ("x-group-timestamp", "x-group-nonce", "x-group-signature"):
if len(request.headers.getlist(name)) != 1:
raise AuthorizationError(401, "集团服务认证无效")
raw = bytearray()
async for chunk in request.stream():
if len(raw) + len(chunk) > MAX_REQUEST_BYTES:
raise AuthorizationError(413, "客服授权请求过大")
raw.extend(chunk)
import asyncio
status, body, headers = await asyncio.to_thread(service.respond, bytes(raw), request.headers,
path, query=bool(request.url.query))
return Response(content=body, status_code=status, media_type="application/json", headers=headers)
except group_access.AccessError as exc:
return JSONResponse(status_code=exc.status, content={"detail": str(exc)}, headers={"Cache-Control": "no-store"})