Проект разделен на два репозитория

This commit is contained in:
mi
2026-08-14 15:42:45 +03:00
parent e06a77ee1d
commit bbef7a30c9
521 changed files with 2597 additions and 2302 deletions
@@ -0,0 +1,13 @@
FROM python:3.12-slim AS runtime
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
RUN addgroup --system app && adduser --system --ingroup app app
WORKDIR /service
COPY app ./app
COPY pyproject.toml ./
RUN pip install --no-cache-dir .
COPY --chmod=0555 container-entrypoint.sh /usr/local/bin/han-container-entrypoint
USER app
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=3s CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8080/health/live', timeout=2)"
ENTRYPOINT ["/usr/local/bin/han-container-entrypoint"]
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8080"]
@@ -0,0 +1 @@
"""HAN message-safety service."""
@@ -0,0 +1,334 @@
from __future__ import annotations
import hashlib
import hmac
import json
import random
import unicodedata
import uuid
from contextlib import asynccontextmanager
from datetime import UTC, datetime, timedelta
from typing import Annotated, Any, Literal, Protocol
import redis.asyncio as redis
import uvicorn
from fastapi import Depends, FastAPI, Header, HTTPException, Request
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from pydantic import BaseModel, ConfigDict, Field, model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(extra="ignore")
app_env: str = "production-like"
message_safety_redis_url: str = "redis://redis:6379/2"
message_safety_service_token: str = Field(min_length=16)
message_safety_rules_version: str = "2026-01-01"
message_safety_task_ttl_sec: int = Field(default=900, ge=330)
message_safety_poll_after_ms: int = Field(default=2000, ge=100, le=30000)
safety_stub_worker_mode: Literal["emulated_on_poll"] = "emulated_on_poll"
safety_stub_rng_seed: int | None = None
@model_validator(mode="after")
def forbid_seed_outside_tests(self) -> Settings:
if self.safety_stub_rng_seed is not None and self.app_env != "test":
raise ValueError("SAFETY_STUB_RNG_SEED is allowed only when APP_ENV=test")
return self
class Attachment(BaseModel):
model_config = ConfigDict(extra="forbid")
attachment_id: uuid.UUID
quarantine_object_key: str = Field(min_length=1, max_length=1024)
mime_type: str = Field(min_length=1, max_length=255)
size_bytes: int = Field(ge=0, le=10 * 1024 * 1024)
checksum: str = Field(pattern=r"^sha256:[0-9a-fA-F]{64}$")
class CheckRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
message_id: uuid.UUID
content_kind: Literal["text", "file"]
text: str = Field(default="", max_length=10000)
attachment: Attachment | None = None
@model_validator(mode="after")
def validate_kind(self) -> CheckRequest:
if self.content_kind == "file" and self.attachment is None:
raise ValueError("attachment is required for file content")
if self.content_kind == "text" and self.attachment is not None:
raise ValueError("attachment is forbidden for text content")
return self
class TaskStore(Protocol):
async def reserve(self, message_id: str, fingerprint: str, task_id: str, ttl: int) -> str: ...
async def poll(self, task_id: str) -> int | None: ...
async def ready(self) -> bool: ...
async def close(self) -> None: ...
class RedisTaskStore:
_reserve_lua = """
local prior = redis.call('GET', KEYS[1])
if prior then
local sep = string.find(prior, '|', 1, true)
local old_fp = string.sub(prior, 1, sep - 1)
if old_fp ~= ARGV[1] then return {'conflict'} end
return {'existing', string.sub(prior, sep + 1)}
end
redis.call('HSET', KEYS[2], 'schema_version', '1', 'message_id', ARGV[2],
'created_at_ms', ARGV[3], 'poll_count', '0', 'rules_version', ARGV[5])
redis.call('EXPIRE', KEYS[2], ARGV[4])
redis.call('SET', KEYS[1], ARGV[1] .. '|' .. ARGV[6], 'EX', ARGV[4])
return {'created', ARGV[6]}
"""
_poll_lua = """
if redis.call('EXISTS', KEYS[1]) == 0 then return nil end
return redis.call('HINCRBY', KEYS[1], 'poll_count', 1)
"""
def __init__(self, client: redis.Redis, rules_version: str) -> None:
self.client = client
self.rules_version = rules_version
async def reserve(self, message_id: str, fingerprint: str, task_id: str, ttl: int) -> str:
result = await self.client.eval(
self._reserve_lua,
2,
f"han:safety:task-by-message:{message_id}",
f"han:safety:task:{task_id}",
fingerprint,
message_id,
str(int(datetime.now(UTC).timestamp() * 1000)),
str(ttl),
self.rules_version,
task_id,
)
status = _decode(result[0])
if status == "conflict":
raise ValueError("conflict")
return _decode(result[1])
async def poll(self, task_id: str) -> int | None:
key = f"han:safety:task:{task_id}"
count = await self.client.eval(self._poll_lua, 1, key)
return int(count) if count is not None else None
async def ready(self) -> bool:
key = f"han:safety:ready:{uuid.uuid4()}"
try:
await self.client.set(key, "1", ex=5)
return await self.client.get(key) == b"1"
finally:
await self.client.delete(key)
async def close(self) -> None:
await self.client.aclose()
def _decode(value: Any) -> str:
return value.decode() if isinstance(value, bytes) else str(value)
def normalize(text: str) -> str:
return unicodedata.normalize("NFKC", text.replace("\r\n", "\n").replace("\r", "\n")).lstrip()
def fingerprint(dto: CheckRequest) -> str:
body = dto.model_dump(mode="json")
body["text"] = normalize(dto.text)
encoded = json.dumps(body, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode()
return hashlib.sha256(encoded).hexdigest()
def error(code: str, message: str, request_id: str, details: dict[str, Any] | None = None) -> dict:
return {
"error": {
"code": code,
"message": message,
"request_id": request_id,
"details": details or {},
}
}
def create_app(
settings: Settings | None = None,
store: TaskStore | None = None,
rng: random.Random | None = None,
) -> FastAPI:
cfg = settings or Settings()
verdict_rng = rng or random.Random(cfg.safety_stub_rng_seed)
@asynccontextmanager
async def lifespan(app: FastAPI):
app.state.store = store or RedisTaskStore(
redis.from_url(cfg.message_safety_redis_url, decode_responses=False),
cfg.message_safety_rules_version,
)
yield
await app.state.store.close()
app = FastAPI(
title="HAN Message Safety",
version="1.0.0",
lifespan=lifespan,
docs_url=None if cfg.app_env != "test" else "/docs",
)
app.state.settings = cfg
@app.middleware("http")
async def request_id_middleware(request: Request, call_next):
request.state.request_id = request.headers.get("X-Request-ID") or str(uuid.uuid4())
response = await call_next(request)
response.headers["X-Request-ID"] = request.state.request_id
return response
def authorize(
request: Request,
token: Annotated[str | None, Header(alias="X-Service-Token")] = None,
) -> None:
if token is None or not hmac.compare_digest(token, cfg.message_safety_service_token):
raise HTTPException(
401,
error(
"service_unauthorized",
"Service authentication failed",
request.state.request_id,
),
)
@app.exception_handler(HTTPException)
async def http_error(_: Request, exc: HTTPException):
return JSONResponse(status_code=exc.status_code, content=exc.detail)
@app.exception_handler(RequestValidationError)
async def validation_error(request: Request, _: RequestValidationError):
return JSONResponse(
error("validation_error", "Request is invalid", request.state.request_id),
status_code=400,
)
@app.get("/health/live")
async def live() -> dict[str, str]:
return {"status": "live"}
@app.get("/health/ready")
async def ready(request: Request):
try:
ok = await request.app.state.store.ready()
except Exception:
ok = False
body = {
"status": "ready" if ok else "not_ready",
"components": {"redis": "ok" if ok else "down"},
}
return JSONResponse(body, status_code=200 if ok else 503)
@app.post("/internal/safety/v1/messages/check", dependencies=[Depends(authorize)])
async def check(dto: CheckRequest, request: Request):
text = normalize(dto.text)
common = {"rules_version": cfg.message_safety_rules_version}
if text and text[0] in {"ф", "Ф"}:
return JSONResponse(
{
"verdict": "deny",
"rule_id": "stub.starts_with_cyrillic_ef",
"reason_code": "stub_blocked",
**common,
},
status_code=403,
)
if text and unicodedata.category(text[0]) == "Nd":
task_id = str(uuid.uuid4())
try:
task_id = await request.app.state.store.reserve(
str(dto.message_id), fingerprint(dto), task_id, cfg.message_safety_task_ttl_sec
)
except ValueError:
return JSONResponse(
error(
"safety_request_conflict", "message_id was reused", request.state.request_id
),
status_code=409,
)
except Exception:
return JSONResponse(
error(
"redis_unavailable", "Task storage is unavailable", request.state.request_id
),
status_code=503,
)
return JSONResponse(
{
"verdict": "pending",
"task_id": task_id,
"poll_after_ms": cfg.message_safety_poll_after_ms,
"expires_at": (
datetime.now(UTC) + timedelta(seconds=cfg.message_safety_task_ttl_sec)
)
.isoformat()
.replace("+00:00", "Z"),
**common,
},
status_code=203,
)
return {"verdict": "allow", "rule_id": "stub.default_allow", **common}
@app.get("/internal/safety/v1/messages/tasks/{task_id}", dependencies=[Depends(authorize)])
async def task(task_id: str, request: Request):
try:
parsed = str(uuid.UUID(task_id))
except ValueError:
return JSONResponse(
error("validation_error", "Request is invalid", request.state.request_id),
status_code=400,
)
try:
count = await request.app.state.store.poll(parsed)
except Exception:
return JSONResponse(
error("redis_unavailable", "Task storage is unavailable", request.state.request_id),
status_code=503,
)
if count is None:
return JSONResponse(
error("task_not_found", "Task was not found", request.state.request_id),
status_code=404,
)
outcome = verdict_rng.choice(("pending", "allow", "final_error"))
if outcome == "pending":
return JSONResponse(
{
"verdict": "pending",
"task_id": parsed,
"poll_after_ms": cfg.message_safety_poll_after_ms,
},
status_code=203,
)
if outcome == "allow":
return {"verdict": "allow", "task_id": parsed, "rule_id": "stub.random_allow"}
return JSONResponse(
{
"verdict": "deny",
"task_id": parsed,
**error(
"stub_final_error",
"Stub task returned a final negative verdict",
request.state.request_id,
{"terminal": True},
),
},
status_code=400,
)
return app
app = create_app()
def run() -> None:
uvicorn.run("app.main:app", host="0.0.0.0", port=8080)
@@ -0,0 +1,23 @@
#!/bin/sh
set -eu
for name in ${HAN_SECRET_VARS:-}; do
case "$name" in
""|[0-9]*|*[!A-Z0-9_]*)
echo "container secrets: invalid variable name" >&2
exit 64
;;
*) ;;
esac
eval "file=\${${name}_FILE:-}"
if [ -z "$file" ] || [ ! -r "$file" ]; then
echo "container secrets: missing file for $name" >&2
exit 66
fi
value=$(cat "$file")
export "$name=$value"
unset "${name}_FILE"
done
unset HAN_SECRET_VARS
exec "$@"
@@ -0,0 +1,58 @@
openapi: 3.1.0
info: {title: HAN Message Safety, version: 1.0.0}
paths:
/health/live:
get: {responses: {"200": {description: Live}}}
/health/ready:
get: {responses: {"200": {description: Ready}, "503": {description: Redis unavailable}}}
/internal/safety/v1/messages/check:
post:
security: [{ServiceToken: []}]
parameters: [{$ref: "#/components/parameters/RequestId"}]
requestBody:
required: true
content:
application/json:
schema: {$ref: "#/components/schemas/CheckRequest"}
responses:
"200": {description: Allow}
"203": {description: Pending}
"403": {description: Deny}
"409": {description: Conflicting message id}
"503": {description: Redis unavailable}
/internal/safety/v1/messages/tasks/{task_id}:
get:
security: [{ServiceToken: []}]
parameters:
- {name: task_id, in: path, required: true, schema: {type: string, format: uuid}}
- {$ref: "#/components/parameters/RequestId"}
responses:
"200": {description: Allow}
"203": {description: Pending}
"400": {description: Validation error or terminal stub rejection}
"404": {description: Task not found}
components:
securitySchemes:
ServiceToken: {type: apiKey, in: header, name: X-Service-Token}
parameters:
RequestId: {name: X-Request-ID, in: header, required: false, schema: {type: string}}
schemas:
Attachment:
type: object
additionalProperties: false
required: [attachment_id, quarantine_object_key, mime_type, size_bytes, checksum]
properties:
attachment_id: {type: string, format: uuid}
quarantine_object_key: {type: string, maxLength: 1024}
mime_type: {type: string, maxLength: 255}
size_bytes: {type: integer, minimum: 0}
checksum: {type: string, pattern: "^sha256:[0-9a-fA-F]{64}$"}
CheckRequest:
type: object
additionalProperties: false
required: [message_id, content_kind]
properties:
message_id: {type: string, format: uuid}
content_kind: {type: string, enum: [text, file]}
text: {type: string, maxLength: 10000, default: ""}
attachment: {anyOf: [{$ref: "#/components/schemas/Attachment"}, {type: "null"}]}
@@ -0,0 +1,28 @@
[project]
name = "han-message-safety"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = [
"fastapi>=0.116,<1",
"pydantic-settings>=2.10,<3",
"redis>=6,<7",
"uvicorn[standard]>=0.35,<1",
]
[project.optional-dependencies]
dev = ["httpx>=0.28,<1", "pytest>=8.4,<9", "pytest-asyncio>=1,<2", "ruff>=0.12,<1"]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["app"]
[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]
[tool.ruff]
target-version = "py312"
line-length = 100
@@ -0,0 +1,103 @@
import os
import random
import uuid
os.environ.setdefault("MESSAGE_SAFETY_SERVICE_TOKEN", "test-service-token-32-characters")
import httpx
import pytest
from app.main import Settings, create_app, normalize
class Store:
def __init__(self):
self.tasks = {}
async def reserve(self, message_id, fingerprint, task_id, ttl):
prior = self.tasks.get(message_id)
if prior and prior[0] != fingerprint:
raise ValueError("conflict")
if prior:
return prior[1]
self.tasks[message_id] = (fingerprint, task_id)
return task_id
async def poll(self, task_id):
return 1 if any(value[1] == task_id for value in self.tasks.values()) else None
async def ready(self):
return True
async def close(self):
pass
class SequenceRandom(random.Random):
def __init__(self):
self.values = iter(("pending", "allow", "final_error"))
def choice(self, _):
return next(self.values)
@pytest.mark.asyncio
async def test_rules_auth_and_independent_poll():
settings = Settings(
app_env="test",
message_safety_service_token="test-service-token-32-characters",
)
app = create_app(settings, Store(), SequenceRandom())
headers = {"X-Service-Token": settings.message_safety_service_token}
message_id = str(uuid.uuid4())
async with app.router.lifespan_context(app):
async with httpx.AsyncClient(
transport=httpx.ASGITransport(app=app), base_url="http://test"
) as client:
assert (
await client.post(
"/internal/safety/v1/messages/check",
json={
"message_id": str(uuid.uuid4()),
"content_kind": "text",
"text": " Файл",
},
headers=headers,
)
).status_code == 403
pending = await client.post(
"/internal/safety/v1/messages/check",
json={"message_id": message_id, "content_kind": "text", "text": "\u00a0 дней"},
headers=headers,
)
assert pending.status_code == 203
task_id = pending.json()["task_id"]
assert [
(
await client.get(
f"/internal/safety/v1/messages/tasks/{task_id}", headers=headers
)
).status_code
for _ in range(3)
] == [203, 200, 400]
assert (
await client.post(
"/internal/safety/v1/messages/check",
json={
"message_id": str(uuid.uuid4()),
"content_kind": "text",
"text": "документ",
},
headers=headers,
)
).status_code == 200
assert (
await client.post(
"/internal/safety/v1/messages/check",
json={"message_id": str(uuid.uuid4()), "content_kind": "text", "text": "ok"},
)
).status_code == 401
def test_normalization():
assert normalize("\r\n\u00a0 дней") == "7 дней"