Разработана первая версия приложений
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
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 .
|
||||
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)"
|
||||
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,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": "\u00a07 дней"},
|
||||
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\u00a07 дней") == "7 дней"
|
||||
Reference in New Issue
Block a user