release(main): 同步 dev 最新候选改动
Storage Persistence Guard / storage-persistence-audit (push) Has been cancelled
Client Quality Gates / Shared client and Web (push) Has been cancelled
Client Quality Gates / macOS Desktop (push) Has been cancelled
Client Quality Gates / Shared client and Web (pull_request) Has been cancelled
Client Quality Gates / macOS Desktop (pull_request) Has been cancelled
Storage Persistence Guard / storage-persistence-audit (pull_request) Has been cancelled
Storage Persistence Guard / storage-persistence-audit (push) Has been cancelled
Client Quality Gates / Shared client and Web (push) Has been cancelled
Client Quality Gates / macOS Desktop (push) Has been cancelled
Client Quality Gates / Shared client and Web (pull_request) Has been cancelled
Client Quality Gates / macOS Desktop (pull_request) Has been cancelled
Storage Persistence Guard / storage-persistence-audit (pull_request) Has been cancelled
This commit is contained in:
@@ -643,6 +643,43 @@ API_ENDPOINT_PERMISSIONS = {
|
||||
"description": "删除文档",
|
||||
"default_roles": ["PM"],
|
||||
},
|
||||
# 共享库在线协作(文档级邀请权限在接口权限之后继续校验)
|
||||
"collaboration:create": {
|
||||
"module": "shared_library",
|
||||
"action": "write",
|
||||
"description": "创建在线协作文件",
|
||||
"default_roles": ["PM", "CRA", "PV", "QA"],
|
||||
},
|
||||
"collaboration:read": {
|
||||
"module": "shared_library",
|
||||
"action": "read",
|
||||
"description": "查看在线协作文件",
|
||||
"default_roles": ["PM", "CRA", "PV", "QA", "CTA"],
|
||||
},
|
||||
"collaboration:edit": {
|
||||
"module": "shared_library",
|
||||
"action": "write",
|
||||
"description": "编辑受邀在线协作文件",
|
||||
"default_roles": ["PM", "CRA", "PV", "QA", "CTA"],
|
||||
},
|
||||
"collaboration:manage": {
|
||||
"module": "shared_library",
|
||||
"action": "write",
|
||||
"description": "管理在线协作文件成员与目录",
|
||||
"default_roles": ["PM", "CRA", "PV", "QA"],
|
||||
},
|
||||
"collaboration:export": {
|
||||
"module": "shared_library",
|
||||
"action": "export",
|
||||
"description": "另存为在线协作文件副本",
|
||||
"default_roles": ["PM", "CRA", "PV", "QA"],
|
||||
},
|
||||
"collaboration:delete": {
|
||||
"module": "shared_library",
|
||||
"action": "write",
|
||||
"description": "移入或恢复在线协作文件",
|
||||
"default_roles": ["PM", "CRA", "PV", "QA"],
|
||||
},
|
||||
}
|
||||
|
||||
def _operation_read_candidates(operation_key: str) -> list[str]:
|
||||
@@ -843,6 +880,7 @@ OPERATION_TO_ENDPOINTS: dict[str, dict[str, list[str]]] = {
|
||||
"precautions:read",
|
||||
"faq:read",
|
||||
"faq_category:read",
|
||||
"collaboration:read",
|
||||
],
|
||||
"write": [
|
||||
"precautions:create",
|
||||
@@ -858,6 +896,11 @@ OPERATION_TO_ENDPOINTS: dict[str, dict[str, list[str]]] = {
|
||||
"faq_reply:create",
|
||||
"faq_reply:delete",
|
||||
"faq_attachments:delete",
|
||||
"collaboration:create",
|
||||
"collaboration:edit",
|
||||
"collaboration:manage",
|
||||
"collaboration:export",
|
||||
"collaboration:delete",
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from functools import lru_cache
|
||||
from typing import Literal, Optional
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from pydantic import Field
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
@@ -19,6 +20,7 @@ class Settings(BaseSettings):
|
||||
JWT_EXPIRE_MINUTES: int = 60
|
||||
JWT_EXTEND_GRACE_SECONDS: int = 120
|
||||
ABSOLUTE_SESSION_MAX_HOURS: int = 8
|
||||
DESKTOP_SESSION_MAX_DAYS: int = 30
|
||||
LOGIN_RSA_PRIVATE_KEY: Optional[str] = None
|
||||
LOGIN_RSA_PUBLIC_KEY: Optional[str] = None
|
||||
LOGIN_RSA_KEY_ID: str = "default"
|
||||
@@ -32,6 +34,31 @@ class Settings(BaseSettings):
|
||||
)
|
||||
IP2REGION_XDB_PATH: Optional[str] = None
|
||||
IP2REGION_IPV6_XDB_PATH: Optional[str] = None
|
||||
TRUSTED_PROXY_CIDRS: str = "127.0.0.1/32,::1/128,172.16.0.0/12"
|
||||
MONITORING_SERVER_PUBLIC_IP: Optional[str] = None
|
||||
MONITORING_PUBLIC_IP_DISCOVERY_URLS: str = (
|
||||
"https://api64.ipify.org,https://icanhazip.com"
|
||||
)
|
||||
MONITORING_PUBLIC_IP_DISCOVERY_TIMEOUT_SECONDS: float = Field(default=2.5, ge=0.5, le=10)
|
||||
MONITORING_SERVER_LOCATION_CACHE_SECONDS: int = Field(default=86400, ge=300, le=604800)
|
||||
MONITORING_IP_GEO_FALLBACK_ENABLED: bool = True
|
||||
MONITORING_IP_GEO_FALLBACK_API_KEY: Optional[str] = None
|
||||
MONITORING_IP_GEO_FALLBACK_TIMEOUT_SECONDS: float = Field(default=2.5, ge=0.5, le=10)
|
||||
MONITORING_IP_GEO_FALLBACK_CACHE_SECONDS: int = Field(default=604800, ge=3600, le=2592000)
|
||||
MONITORING_IP_GEO_FALLBACK_MAX_LOOKUPS: int = Field(default=10, ge=1, le=100)
|
||||
MONITORING_ACCESS_LOG_RETENTION_DAYS: int = Field(default=90, ge=7, le=3650)
|
||||
MONITORING_METRIC_RETENTION_DAYS: int = Field(default=400, ge=30, le=3650)
|
||||
MONITORING_RETENTION_INTERVAL_SECONDS: int = Field(default=86400, ge=60, le=604800)
|
||||
USER_LOGIN_ACTIVITY_RETENTION_DAYS: int = Field(default=180, ge=30, le=3650)
|
||||
USER_SESSION_ONLINE_SECONDS: int = Field(default=300, ge=60, le=3600)
|
||||
NOTIFICATION_SYNC_INTERVAL_SECONDS: int = Field(default=300, ge=60, le=3600)
|
||||
ONLYOFFICE_ENABLED: bool = False
|
||||
ONLYOFFICE_JWT_SECRET: Optional[str] = None
|
||||
ONLYOFFICE_INTERNAL_URL: str = "http://onlyoffice"
|
||||
ONLYOFFICE_STORAGE_BASE_URL: str = "http://backend:8000"
|
||||
ONLYOFFICE_INSTANCE_ID: Optional[str] = None
|
||||
ONLYOFFICE_CONFIG_TTL_SECONDS: int = Field(default=300, ge=60, le=900)
|
||||
COLLABORATION_MAX_FILE_BYTES: int = Field(default=50 * 1024 * 1024, ge=1024, le=500 * 1024 * 1024)
|
||||
|
||||
|
||||
@lru_cache
|
||||
@@ -42,6 +69,30 @@ def get_settings() -> Settings:
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
def validate_onlyoffice_configuration() -> None:
|
||||
if not settings.ONLYOFFICE_ENABLED:
|
||||
return
|
||||
secret = (settings.ONLYOFFICE_JWT_SECRET or "").strip()
|
||||
instance_id = (settings.ONLYOFFICE_INSTANCE_ID or "").strip()
|
||||
if secret == settings.JWT_SECRET_KEY:
|
||||
raise RuntimeError("ONLYOFFICE_JWT_SECRET must not reuse JWT_SECRET_KEY")
|
||||
if len(secret) < 32:
|
||||
raise RuntimeError("ONLYOFFICE_JWT_SECRET must contain at least 32 characters when ONLYOFFICE is enabled")
|
||||
if not instance_id:
|
||||
raise RuntimeError("ONLYOFFICE_INSTANCE_ID is required when ONLYOFFICE is enabled")
|
||||
for name, value in (
|
||||
("ONLYOFFICE_INTERNAL_URL", settings.ONLYOFFICE_INTERNAL_URL),
|
||||
("ONLYOFFICE_STORAGE_BASE_URL", settings.ONLYOFFICE_STORAGE_BASE_URL),
|
||||
):
|
||||
parsed = urlsplit(value.strip())
|
||||
if parsed.scheme.lower() not in {"http", "https"} or not parsed.hostname:
|
||||
raise RuntimeError(f"{name} must be an HTTP(S) URL")
|
||||
if parsed.username or parsed.password or parsed.query or parsed.fragment:
|
||||
raise RuntimeError(f"{name} must not contain credentials, a query, or a fragment")
|
||||
if parsed.path not in {"", "/"}:
|
||||
raise RuntimeError(f"{name} must not contain a path")
|
||||
|
||||
|
||||
def get_cors_allowed_origins() -> list[str]:
|
||||
return [
|
||||
origin.strip()
|
||||
|
||||
@@ -286,8 +286,9 @@ def _enqueue_permission_log(
|
||||
|
||||
writer = get_log_writer()
|
||||
if writer:
|
||||
forwarded = request.headers.get("x-forwarded-for")
|
||||
ip = forwarded.split(",")[0].strip() if forwarded else (request.client.host if request.client else None)
|
||||
from app.core.request_context import build_request_audit_context, get_request_audit_context
|
||||
|
||||
context = get_request_audit_context() or build_request_audit_context(request)
|
||||
writer.enqueue({
|
||||
"study_id": study_id,
|
||||
"user_id": user_id,
|
||||
@@ -295,7 +296,16 @@ def _enqueue_permission_log(
|
||||
"role": role,
|
||||
"allowed": allowed,
|
||||
"elapsed_ms": elapsed_ms,
|
||||
"ip_address": ip,
|
||||
"ip_address": context.client_ip,
|
||||
"user_agent": context.user_agent,
|
||||
"client_type": context.client_type,
|
||||
"client_version": context.client_version,
|
||||
"client_platform": context.client_platform,
|
||||
"build_channel": context.build_channel,
|
||||
"build_commit": context.build_commit,
|
||||
"request_headers": context.request_headers,
|
||||
"request_snapshot": context.request_snapshot,
|
||||
"request_id": context.request_id,
|
||||
})
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
"""Request-scoped metadata used by server-side audit writers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ipaddress
|
||||
import re
|
||||
import uuid
|
||||
from contextvars import ContextVar, Token
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RequestAuditContext:
|
||||
request_id: str | None = None
|
||||
client_ip: str | None = None
|
||||
user_agent: str | None = None
|
||||
client_type: str | None = None
|
||||
client_version: str | None = None
|
||||
client_platform: str | None = None
|
||||
build_channel: str | None = None
|
||||
build_commit: str | None = None
|
||||
request_headers: dict[str, str] | None = None
|
||||
request_snapshot: dict[str, Any] | None = None
|
||||
|
||||
|
||||
_request_audit_context: ContextVar[RequestAuditContext | None] = ContextVar(
|
||||
"request_audit_context",
|
||||
default=None,
|
||||
)
|
||||
|
||||
_SENSITIVE_TEXT_PATTERN = re.compile(
|
||||
r"(?i)(bearer\s+)[A-Za-z0-9._~+/=-]+|((?:access_)?token|authorization|password|passwd|secret|credential|api[-_]?key|session)=([^&\s]+)"
|
||||
)
|
||||
_SENSITIVE_HEADER_NAME_PATTERN = re.compile(
|
||||
r"(?i)(authorization|cookie|set-cookie|token|password|passwd|secret|credential|api[-_]?key|session)"
|
||||
)
|
||||
_SAFE_REQUEST_HEADER_NAMES = frozenset(
|
||||
{
|
||||
"accept",
|
||||
"accept-language",
|
||||
"content-type",
|
||||
"host",
|
||||
"origin",
|
||||
"user-agent",
|
||||
"x-request-id",
|
||||
"x-correlation-id",
|
||||
}
|
||||
)
|
||||
_SAFE_REQUEST_HEADER_PREFIXES = ("x-ctms-",)
|
||||
_SENSITIVE_PARAMETER_NAME_PATTERN = re.compile(
|
||||
r"(?i)(token|authorization|password|passwd|secret|credential|api[-_]?key|session|"
|
||||
r"subject|participant|patient|user_?name|full_?name|email|phone|mobile|id_?card|"
|
||||
r"identity|certificate|contact|address)"
|
||||
)
|
||||
_KNOWN_CLIENT_TYPES = frozenset({"web", "desktop"})
|
||||
_CTMS_CLIENT_SOURCE_TO_TYPE = {
|
||||
"ctms-web": "web",
|
||||
"ctms-desktop": "desktop",
|
||||
}
|
||||
|
||||
|
||||
def _clean_audit_value(value: str | None, max_length: int) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
cleaned = _SENSITIVE_TEXT_PATTERN.sub(lambda m: f"{m.group(1) or m.group(2) + '='}[redacted]", value.strip())
|
||||
if not cleaned:
|
||||
return None
|
||||
return cleaned[:max_length]
|
||||
|
||||
|
||||
def _clean_header_value(value: str | None, max_length: int) -> str | None:
|
||||
return _clean_audit_value(value, max_length)
|
||||
|
||||
|
||||
def resolve_ctms_client_type(headers: Any) -> str | None:
|
||||
source = _clean_header_value(headers.get("x-ctms-client-source"), 32)
|
||||
if source:
|
||||
mapped_type = _CTMS_CLIENT_SOURCE_TO_TYPE.get(source.strip().lower())
|
||||
if mapped_type:
|
||||
return mapped_type
|
||||
|
||||
client_type = _clean_header_value(headers.get("x-ctms-client-type"), 16)
|
||||
if not client_type:
|
||||
return None
|
||||
normalized = client_type.strip().lower()
|
||||
return normalized if normalized in _KNOWN_CLIENT_TYPES else normalized[:16]
|
||||
|
||||
|
||||
def build_sanitized_request_headers(request: Any) -> dict[str, str] | None:
|
||||
captured: dict[str, str] = {}
|
||||
for raw_name, raw_value in request.headers.items():
|
||||
name = str(raw_name).strip().lower()
|
||||
if not name:
|
||||
continue
|
||||
if _SENSITIVE_HEADER_NAME_PATTERN.search(name):
|
||||
captured[name] = "[redacted]"
|
||||
continue
|
||||
if name not in _SAFE_REQUEST_HEADER_NAMES and not name.startswith(_SAFE_REQUEST_HEADER_PREFIXES):
|
||||
continue
|
||||
value = _clean_header_value(str(raw_value), 500)
|
||||
if value:
|
||||
captured[name] = value
|
||||
return captured or None
|
||||
|
||||
|
||||
def _sanitize_named_value(raw_name: Any, raw_value: Any, max_length: int = 500) -> dict[str, str] | None:
|
||||
name = _clean_audit_value(str(raw_name), 120)
|
||||
if not name:
|
||||
return None
|
||||
if _SENSITIVE_PARAMETER_NAME_PATTERN.search(name):
|
||||
return {"name": name, "value": "[redacted]"}
|
||||
value = _clean_audit_value(str(raw_value), max_length)
|
||||
if value is None:
|
||||
return {"name": name, "value": ""}
|
||||
return {"name": name, "value": value}
|
||||
|
||||
|
||||
def _query_param_items(request: Any) -> list[dict[str, str]] | None:
|
||||
query_params = getattr(request, "query_params", None)
|
||||
if not query_params:
|
||||
return None
|
||||
if hasattr(query_params, "multi_items"):
|
||||
raw_items = query_params.multi_items()
|
||||
else:
|
||||
raw_items = query_params.items()
|
||||
items = [
|
||||
item
|
||||
for item in (_sanitize_named_value(name, value) for name, value in raw_items)
|
||||
if item is not None
|
||||
][:50]
|
||||
return items or None
|
||||
|
||||
|
||||
def _sanitized_query_string(items: list[dict[str, str]] | None) -> str | None:
|
||||
if not items:
|
||||
return None
|
||||
value = "&".join(f"{item['name']}={item['value']}" for item in items)
|
||||
return value[:2000] or None
|
||||
|
||||
|
||||
def _request_client_snapshot(request: Any) -> dict[str, str | int | None] | None:
|
||||
client = getattr(request, "client", None)
|
||||
if not client:
|
||||
return None
|
||||
host = _clean_audit_value(getattr(client, "host", None), 120)
|
||||
port = getattr(client, "port", None)
|
||||
if host is None and port is None:
|
||||
return None
|
||||
return {"host": host, "port": port}
|
||||
|
||||
|
||||
def build_request_snapshot(request: Any, *, request_id: str | None = None) -> dict[str, Any]:
|
||||
headers = getattr(request, "headers", {})
|
||||
scope = getattr(request, "scope", {}) or {}
|
||||
url = getattr(request, "url", None)
|
||||
query_params = _query_param_items(request)
|
||||
path = getattr(url, "path", None) or scope.get("path")
|
||||
method = getattr(request, "method", None) or scope.get("method")
|
||||
snapshot: dict[str, Any] = {
|
||||
"request_id": request_id,
|
||||
"method": _clean_audit_value(str(method).upper() if method else None, 12),
|
||||
"path": _clean_audit_value(path, 500),
|
||||
"query_string": _sanitized_query_string(query_params),
|
||||
"query_params": query_params,
|
||||
"headers": build_sanitized_request_headers(request),
|
||||
"http_version": _clean_audit_value(scope.get("http_version"), 16),
|
||||
"scheme": _clean_audit_value(getattr(url, "scheme", None) or scope.get("scheme"), 16),
|
||||
"client": _request_client_snapshot(request),
|
||||
"content": {
|
||||
"type": _clean_audit_value(headers.get("content-type"), 200),
|
||||
"length": _clean_audit_value(headers.get("content-length"), 32),
|
||||
},
|
||||
"body": {
|
||||
"captured": False,
|
||||
"reason": "body_not_captured_by_audit_policy",
|
||||
},
|
||||
}
|
||||
return {key: value for key, value in snapshot.items() if value not in (None, {}, [])}
|
||||
|
||||
|
||||
def _parse_ip(value: str | None):
|
||||
try:
|
||||
return ipaddress.ip_address((value or "").strip())
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _trusted_proxy_networks():
|
||||
networks = []
|
||||
for value in settings.TRUSTED_PROXY_CIDRS.split(","):
|
||||
candidate = value.strip()
|
||||
if not candidate:
|
||||
continue
|
||||
try:
|
||||
networks.append(ipaddress.ip_network(candidate, strict=False))
|
||||
except ValueError:
|
||||
continue
|
||||
return tuple(networks)
|
||||
|
||||
|
||||
def _is_trusted_proxy(value: str | None) -> bool:
|
||||
address = _parse_ip(value)
|
||||
return bool(address and any(address in network for network in _trusted_proxy_networks()))
|
||||
|
||||
|
||||
def resolve_client_ip(request: Any) -> str | None:
|
||||
peer_ip = request.client.host if request.client else None
|
||||
if not _is_trusted_proxy(peer_ip):
|
||||
return _clean_header_value(peer_ip, 45)
|
||||
|
||||
forwarded = request.headers.get("x-forwarded-for")
|
||||
if forwarded:
|
||||
chain = [part.strip() for part in forwarded.split(",") if _parse_ip(part)]
|
||||
chain.append(str(peer_ip))
|
||||
for candidate in reversed(chain):
|
||||
if not _is_trusted_proxy(candidate):
|
||||
return _clean_header_value(candidate, 45)
|
||||
|
||||
real_ip = request.headers.get("x-real-ip")
|
||||
if _parse_ip(real_ip):
|
||||
return _clean_header_value(real_ip.strip(), 45)
|
||||
return _clean_header_value(peer_ip, 45)
|
||||
|
||||
|
||||
def build_request_audit_context(request: Any) -> RequestAuditContext:
|
||||
headers = request.headers
|
||||
request_id = str(uuid.uuid4())
|
||||
return RequestAuditContext(
|
||||
request_id=request_id,
|
||||
client_ip=resolve_client_ip(request),
|
||||
user_agent=_clean_header_value(headers.get("user-agent"), 500),
|
||||
client_type=resolve_ctms_client_type(headers),
|
||||
client_version=_clean_header_value(headers.get("x-ctms-client-version"), 32),
|
||||
client_platform=_clean_header_value(headers.get("x-ctms-client-platform"), 16),
|
||||
build_channel=_clean_header_value(headers.get("x-ctms-build-channel"), 16),
|
||||
build_commit=_clean_header_value(headers.get("x-ctms-build-commit"), 64),
|
||||
request_headers=build_sanitized_request_headers(request),
|
||||
request_snapshot=build_request_snapshot(request, request_id=request_id),
|
||||
)
|
||||
|
||||
|
||||
def set_request_audit_context(context: RequestAuditContext) -> Token[RequestAuditContext | None]:
|
||||
return _request_audit_context.set(context)
|
||||
|
||||
|
||||
def reset_request_audit_context(token: Token[RequestAuditContext | None]) -> None:
|
||||
_request_audit_context.reset(token)
|
||||
|
||||
|
||||
def get_request_audit_context() -> RequestAuditContext | None:
|
||||
return _request_audit_context.get()
|
||||
@@ -19,16 +19,32 @@ def create_access_token(
|
||||
user_id: str,
|
||||
expires_minutes: Optional[int] = None,
|
||||
session_start: Optional[datetime] = None,
|
||||
max_age_seconds: Optional[int] = None,
|
||||
issued_at: Optional[datetime] = None,
|
||||
client_type: Optional[str] = None,
|
||||
session_id: Optional[str] = None,
|
||||
) -> str:
|
||||
now = datetime.now(timezone.utc)
|
||||
now = issued_at or datetime.now(timezone.utc)
|
||||
if now.tzinfo is None:
|
||||
now = now.replace(tzinfo=timezone.utc)
|
||||
expire = now + timedelta(minutes=expires_minutes or settings.JWT_EXPIRE_MINUTES)
|
||||
session_start_time = session_start or now
|
||||
if session_start_time.tzinfo is None:
|
||||
session_start_time = session_start_time.replace(tzinfo=timezone.utc)
|
||||
if max_age_seconds is not None:
|
||||
session_expire = session_start_time + timedelta(seconds=max_age_seconds)
|
||||
if expire > session_expire:
|
||||
expire = session_expire
|
||||
to_encode: Dict[str, Any] = {
|
||||
"sub": user_id,
|
||||
"exp": expire,
|
||||
"iat": int(now.timestamp()),
|
||||
"orig_iat": int(session_start_time.timestamp()),
|
||||
}
|
||||
if client_type:
|
||||
to_encode["client_type"] = client_type
|
||||
if session_id:
|
||||
to_encode["sid"] = session_id
|
||||
return jwt.encode(to_encode, settings.JWT_SECRET_KEY, algorithm=ALGORITHM)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user