361 lines
20 KiB
Python
361 lines
20 KiB
Python
"""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"})
|