Поправили заполлнение БД + поправили гонку сообщений при отправке в Битрикс
This commit is contained in:
@@ -139,6 +139,7 @@ BITRIX_PUBLIC_BASE_URL=https://chat.example.ru/bitrix
|
|||||||
# Generate with: python -c "import base64,secrets; print(base64.urlsafe_b64encode(secrets.token_bytes(32)).decode())"
|
# Generate with: python -c "import base64,secrets; print(base64.urlsafe_b64encode(secrets.token_bytes(32)).decode())"
|
||||||
BITRIX_TOKEN_ENCRYPTION_KEY=change-me
|
BITRIX_TOKEN_ENCRYPTION_KEY=change-me
|
||||||
BITRIX_HTTP_TIMEOUT_SEC=10
|
BITRIX_HTTP_TIMEOUT_SEC=10
|
||||||
|
BITRIX_LOCAL_APP_HTTP_TIMEOUT_SEC=20
|
||||||
|
|
||||||
SELECTEL_S3_ENDPOINT_URL=https://s3.ru-7.storage.selcloud.ru
|
SELECTEL_S3_ENDPOINT_URL=https://s3.ru-7.storage.selcloud.ru
|
||||||
SELECTEL_S3_BUCKET_DOCUMENTS=han-chat-documents
|
SELECTEL_S3_BUCKET_DOCUMENTS=han-chat-documents
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
"""Store consent device snapshots and remove audit IP.
|
||||||
|
|
||||||
|
Revision ID: 0003_consent_audit
|
||||||
|
Revises: 0002_pgcrypto_digest
|
||||||
|
Create Date: 2026-07-16
|
||||||
|
"""
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
revision: str = "0003_consent_audit"
|
||||||
|
down_revision: str | None = "0002_pgcrypto_digest"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
ALTER TABLE han_app.user_consents
|
||||||
|
ADD COLUMN IF NOT EXISTS device_json jsonb NOT NULL DEFAULT '{}'::jsonb
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
ALTER TABLE han_app.audit_events
|
||||||
|
DROP COLUMN IF EXISTS ip
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
raise RuntimeError("Consent and audit context migration is forward-only")
|
||||||
@@ -17,7 +17,7 @@ from sqlalchemy import (
|
|||||||
UniqueConstraint,
|
UniqueConstraint,
|
||||||
func,
|
func,
|
||||||
)
|
)
|
||||||
from sqlalchemy.dialects.postgresql import INET, UUID
|
from sqlalchemy.dialects.postgresql import INET, JSONB, UUID
|
||||||
from sqlalchemy.ext.asyncio import (
|
from sqlalchemy.ext.asyncio import (
|
||||||
AsyncEngine,
|
AsyncEngine,
|
||||||
AsyncSession,
|
AsyncSession,
|
||||||
@@ -86,6 +86,7 @@ class UserConsent(Common, Base):
|
|||||||
accepted_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
|
accepted_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
|
||||||
client_ip: Mapped[str | None] = mapped_column(INET)
|
client_ip: Mapped[str | None] = mapped_column(INET)
|
||||||
user_agent_hash: Mapped[str | None] = mapped_column(String(64))
|
user_agent_hash: Mapped[str | None] = mapped_column(String(64))
|
||||||
|
device_json: Mapped[dict[str, Any]] = mapped_column(JSONB, default=dict)
|
||||||
|
|
||||||
|
|
||||||
class UxSession(Common, Base):
|
class UxSession(Common, Base):
|
||||||
@@ -275,7 +276,6 @@ class AuditEvent(Base):
|
|||||||
trace_id: Mapped[str | None] = mapped_column(String(64))
|
trace_id: Mapped[str | None] = mapped_column(String(64))
|
||||||
resource_type: Mapped[str | None] = mapped_column(String(64))
|
resource_type: Mapped[str | None] = mapped_column(String(64))
|
||||||
resource_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True))
|
resource_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True))
|
||||||
ip: Mapped[str | None] = mapped_column(INET)
|
|
||||||
user_agent_hash: Mapped[str | None] = mapped_column(String(64))
|
user_agent_hash: Mapped[str | None] = mapped_column(String(64))
|
||||||
outcome: Mapped[str] = mapped_column(String(32))
|
outcome: Mapped[str] = mapped_column(String(32))
|
||||||
metadata_json: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
|
metadata_json: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ from app.db import (
|
|||||||
PopularQuestion,
|
PopularQuestion,
|
||||||
TextResource,
|
TextResource,
|
||||||
UserIdentity,
|
UserIdentity,
|
||||||
|
UxSession,
|
||||||
)
|
)
|
||||||
from app.integrations import (
|
from app.integrations import (
|
||||||
DependencyFailure,
|
DependencyFailure,
|
||||||
@@ -62,6 +63,7 @@ from app.schemas import (
|
|||||||
encode_cursor,
|
encode_cursor,
|
||||||
)
|
)
|
||||||
from app.services import (
|
from app.services import (
|
||||||
|
AuditContext,
|
||||||
DomainError,
|
DomainError,
|
||||||
SettingsSnapshot,
|
SettingsSnapshot,
|
||||||
apply_inbox,
|
apply_inbox,
|
||||||
@@ -153,7 +155,7 @@ app = FastAPI(
|
|||||||
log = structlog.get_logger()
|
log = structlog.get_logger()
|
||||||
|
|
||||||
|
|
||||||
def client_ip(request: Request) -> str:
|
def client_ip(request: Request) -> str | None:
|
||||||
"""Trust forwarded client addresses only from configured reverse proxies."""
|
"""Trust forwarded client addresses only from configured reverse proxies."""
|
||||||
peer = request.client.host if request.client else ""
|
peer = request.client.host if request.client else ""
|
||||||
try:
|
try:
|
||||||
@@ -170,7 +172,40 @@ def client_ip(request: Request) -> str:
|
|||||||
try:
|
try:
|
||||||
return str(ip_address(candidate))
|
return str(ip_address(candidate))
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return "unknown"
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def request_trace_id(request: Request) -> str:
|
||||||
|
parts = request.headers.get("traceparent", "").lower().split("-")
|
||||||
|
if (
|
||||||
|
len(parts) == 4
|
||||||
|
and len(parts[0]) == 2
|
||||||
|
and len(parts[1]) == 32
|
||||||
|
and len(parts[2]) == 16
|
||||||
|
and len(parts[3]) == 2
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
int("".join(parts), 16)
|
||||||
|
if parts[1] != "0" * 32 and parts[2] != "0" * 16:
|
||||||
|
return parts[1]
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
return uuid.uuid4().hex
|
||||||
|
|
||||||
|
|
||||||
|
def user_agent_hash(request: Request) -> str | None:
|
||||||
|
value = " ".join(request.headers.get("User-Agent", "").split())
|
||||||
|
return hashlib.sha256(value[:1024].encode()).hexdigest() if value else None
|
||||||
|
|
||||||
|
|
||||||
|
def audit_context(request: Request, ux_session_id: uuid.UUID | None = None) -> AuditContext:
|
||||||
|
return AuditContext(
|
||||||
|
request_id=request.state.request_id,
|
||||||
|
trace_id=request.state.trace_id,
|
||||||
|
ux_session_id=ux_session_id,
|
||||||
|
user_agent_hash=request.state.user_agent_hash,
|
||||||
|
client_ip=client_ip(request),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.middleware("http")
|
@app.middleware("http")
|
||||||
@@ -181,10 +216,13 @@ async def request_context(request: Request, call_next: Any) -> Response:
|
|||||||
except ValueError:
|
except ValueError:
|
||||||
request_id = str(uuid.uuid4())
|
request_id = str(uuid.uuid4())
|
||||||
request.state.request_id = request_id
|
request.state.request_id = request_id
|
||||||
|
request.state.trace_id = request_trace_id(request)
|
||||||
|
request.state.user_agent_hash = user_agent_hash(request)
|
||||||
request.state.started_at = time.monotonic()
|
request.state.started_at = time.monotonic()
|
||||||
structlog.contextvars.clear_contextvars()
|
structlog.contextvars.clear_contextvars()
|
||||||
structlog.contextvars.bind_contextvars(
|
structlog.contextvars.bind_contextvars(
|
||||||
request_id=request_id,
|
request_id=request_id,
|
||||||
|
trace_id=request.state.trace_id,
|
||||||
ux_session_id=request.headers.get("X-Ux-Session-Id"),
|
ux_session_id=request.headers.get("X-Ux-Session-Id"),
|
||||||
method=request.method,
|
method=request.method,
|
||||||
route=request.url.path,
|
route=request.url.path,
|
||||||
@@ -291,6 +329,26 @@ async def current_user(db: Session, auth: PrincipalDep) -> UserIdentity:
|
|||||||
UserDep = Annotated[UserIdentity, Depends(current_user)]
|
UserDep = Annotated[UserIdentity, Depends(current_user)]
|
||||||
|
|
||||||
|
|
||||||
|
async def required_user_audit_context(
|
||||||
|
request: Request,
|
||||||
|
db: Session,
|
||||||
|
user: UserDep,
|
||||||
|
x_ux_session_id: Annotated[str, Header(alias="X-Ux-Session-Id")],
|
||||||
|
) -> AuditContext:
|
||||||
|
session_id = ux_id(x_ux_session_id)
|
||||||
|
if session_id is None:
|
||||||
|
raise DomainError("validation_error", 400, "X-Ux-Session-Id is required")
|
||||||
|
exists = await db.scalar(
|
||||||
|
select(UxSession.id).where(UxSession.id == session_id, UxSession.user_id == user.id)
|
||||||
|
)
|
||||||
|
if exists is None:
|
||||||
|
raise DomainError("validation_error", 400, "X-Ux-Session-Id is invalid")
|
||||||
|
return audit_context(request, session_id)
|
||||||
|
|
||||||
|
|
||||||
|
UserAuditContextDep = Annotated[AuditContext, Depends(required_user_audit_context)]
|
||||||
|
|
||||||
|
|
||||||
async def snapshot(db: Session) -> SettingsSnapshot:
|
async def snapshot(db: Session) -> SettingsSnapshot:
|
||||||
return await load_settings(db)
|
return await load_settings(db)
|
||||||
|
|
||||||
@@ -357,7 +415,7 @@ async def ready(request: Request, db: Session):
|
|||||||
try:
|
try:
|
||||||
await db.execute(text("SELECT 1"))
|
await db.execute(text("SELECT 1"))
|
||||||
revision = await db.scalar(text("SELECT version_num FROM han_app.alembic_version LIMIT 1"))
|
revision = await db.scalar(text("SELECT version_num FROM han_app.alembic_version LIMIT 1"))
|
||||||
if revision != "0002_pgcrypto_digest":
|
if revision != "0003_consent_audit":
|
||||||
raise RuntimeError("unexpected database revision")
|
raise RuntimeError("unexpected database revision")
|
||||||
await load_settings(db)
|
await load_settings(db)
|
||||||
components["postgres"] = "ok"
|
components["postgres"] = "ok"
|
||||||
@@ -400,7 +458,7 @@ async def app_config(request: Request, response: Response, settings: SnapshotDep
|
|||||||
await enforce_limit(
|
await enforce_limit(
|
||||||
request,
|
request,
|
||||||
"ip",
|
"ip",
|
||||||
client_ip(request),
|
client_ip(request) or "unknown",
|
||||||
"public",
|
"public",
|
||||||
settings.limit("rate_limit.public_endpoints.per_ip"),
|
settings.limit("rate_limit.public_endpoints.per_ip"),
|
||||||
fail_closed=False,
|
fail_closed=False,
|
||||||
@@ -444,7 +502,7 @@ async def content(
|
|||||||
await enforce_limit(
|
await enforce_limit(
|
||||||
request,
|
request,
|
||||||
"ip",
|
"ip",
|
||||||
client_ip(request),
|
client_ip(request) or "unknown",
|
||||||
"public",
|
"public",
|
||||||
settings.limit("rate_limit.public_endpoints.per_ip"),
|
settings.limit("rate_limit.public_endpoints.per_ip"),
|
||||||
fail_closed=False,
|
fail_closed=False,
|
||||||
@@ -480,7 +538,7 @@ async def content(
|
|||||||
async def auth_bootstrap(
|
async def auth_bootstrap(
|
||||||
body: BootstrapRequest, request: Request, db: Session, auth: PrincipalDep, settings: SnapshotDep
|
body: BootstrapRequest, request: Request, db: Session, auth: PrincipalDep, settings: SnapshotDep
|
||||||
):
|
):
|
||||||
return await bootstrap(db, auth, body, settings, request.state.request_id)
|
return await bootstrap(db, auth, body, settings, audit_context(request))
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/v1/consents", status_code=201, tags=["auth"])
|
@app.post("/api/v1/consents", status_code=201, tags=["auth"])
|
||||||
@@ -490,18 +548,16 @@ async def consents(
|
|||||||
db: Session,
|
db: Session,
|
||||||
user: UserDep,
|
user: UserDep,
|
||||||
settings: SnapshotDep,
|
settings: SnapshotDep,
|
||||||
x_ux_session_id: Annotated[str | None, Header()] = None,
|
context: UserAuditContextDep,
|
||||||
):
|
):
|
||||||
return await record_consents(
|
return await record_consents(db, user, body, settings, context)
|
||||||
db, user, body, settings, request.state.request_id, ux_id(x_ux_session_id)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/v1/analytics/session-start", status_code=201, tags=["analytics"])
|
@app.post("/api/v1/analytics/session-start", status_code=201, tags=["analytics"])
|
||||||
async def analytics_session(
|
async def analytics_session(
|
||||||
body: SessionStartRequest, request: Request, db: Session, user: UserDep
|
body: SessionStartRequest, request: Request, db: Session, user: UserDep
|
||||||
):
|
):
|
||||||
return await start_session(db, user, body, request.state.request_id)
|
return await start_session(db, user, body, audit_context(request))
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/v1/me", tags=["profile"])
|
@app.get("/api/v1/me", tags=["profile"])
|
||||||
@@ -564,7 +620,7 @@ async def document_download(
|
|||||||
db: Session,
|
db: Session,
|
||||||
user: UserDep,
|
user: UserDep,
|
||||||
settings: SnapshotDep,
|
settings: SnapshotDep,
|
||||||
x_ux_session_id: Annotated[str | None, Header()] = None,
|
context: UserAuditContextDep,
|
||||||
):
|
):
|
||||||
await enforce_limit(
|
await enforce_limit(
|
||||||
request,
|
request,
|
||||||
@@ -578,11 +634,11 @@ async def document_download(
|
|||||||
db.add(
|
db.add(
|
||||||
audit(
|
audit(
|
||||||
"document.download_url_issued",
|
"document.download_url_issued",
|
||||||
request.state.request_id,
|
context,
|
||||||
user.id,
|
user.id,
|
||||||
"document",
|
"document",
|
||||||
item.id,
|
item.id,
|
||||||
ux_id(x_ux_session_id),
|
metadata={"expires_in_seconds": 300},
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
@@ -595,6 +651,7 @@ async def dialogs_create(
|
|||||||
request: Request,
|
request: Request,
|
||||||
db: Session,
|
db: Session,
|
||||||
user: UserDep,
|
user: UserDep,
|
||||||
|
context: UserAuditContextDep,
|
||||||
idempotency_key: Annotated[str | None, Header()] = None,
|
idempotency_key: Annotated[str | None, Header()] = None,
|
||||||
):
|
):
|
||||||
key = await required_idempotency(idempotency_key)
|
key = await required_idempotency(idempotency_key)
|
||||||
@@ -608,7 +665,7 @@ async def dialogs_create(
|
|||||||
if cached["fingerprint"] != fingerprint:
|
if cached["fingerprint"] != fingerprint:
|
||||||
raise DomainError("idempotency_key_reused", 409, "Idempotency key was reused")
|
raise DomainError("idempotency_key_reused", 409, "Idempotency key was reused")
|
||||||
return JSONResponse(cached["body"], status_code=cached["status"])
|
return JSONResponse(cached["body"], status_code=cached["status"])
|
||||||
body, status = await create_dialog(db, user, request.state.request_id, key)
|
body, status = await create_dialog(db, user, context, key)
|
||||||
try:
|
try:
|
||||||
await request.app.state.idempotency.put(
|
await request.app.state.idempotency.put(
|
||||||
scope,
|
scope,
|
||||||
@@ -735,6 +792,7 @@ async def message_create(
|
|||||||
db: Session,
|
db: Session,
|
||||||
user: UserDep,
|
user: UserDep,
|
||||||
business: SnapshotDep,
|
business: SnapshotDep,
|
||||||
|
context: UserAuditContextDep,
|
||||||
idempotency_key: Annotated[str | None, Header()] = None,
|
idempotency_key: Annotated[str | None, Header()] = None,
|
||||||
):
|
):
|
||||||
key = await required_idempotency(idempotency_key)
|
key = await required_idempotency(idempotency_key)
|
||||||
@@ -772,7 +830,7 @@ async def message_create(
|
|||||||
dialog_id,
|
dialog_id,
|
||||||
body,
|
body,
|
||||||
key,
|
key,
|
||||||
request.state.request_id,
|
context,
|
||||||
request.app.state.settings,
|
request.app.state.settings,
|
||||||
request.app.state.safety,
|
request.app.state.safety,
|
||||||
request.app.state.openlines,
|
request.app.state.openlines,
|
||||||
@@ -803,6 +861,7 @@ async def attachment_init(
|
|||||||
db: Session,
|
db: Session,
|
||||||
user: UserDep,
|
user: UserDep,
|
||||||
settings: SnapshotDep,
|
settings: SnapshotDep,
|
||||||
|
context: UserAuditContextDep,
|
||||||
):
|
):
|
||||||
await enforce_limit(
|
await enforce_limit(
|
||||||
request,
|
request,
|
||||||
@@ -812,9 +871,7 @@ async def attachment_init(
|
|||||||
settings.limit("rate_limit.message_send.per_user"),
|
settings.limit("rate_limit.message_send.per_user"),
|
||||||
fail_closed=True,
|
fail_closed=True,
|
||||||
)
|
)
|
||||||
return await init_attachment(
|
return await init_attachment(db, user, dialog_id, body, settings, request.app.state.s3, context)
|
||||||
db, user, dialog_id, body, settings, request.app.state.s3, request.state.request_id
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@app.post(
|
@app.post(
|
||||||
@@ -828,9 +885,10 @@ async def attachment_complete(
|
|||||||
request: Request,
|
request: Request,
|
||||||
db: Session,
|
db: Session,
|
||||||
user: UserDep,
|
user: UserDep,
|
||||||
|
context: UserAuditContextDep,
|
||||||
):
|
):
|
||||||
return await complete_attachment(
|
return await complete_attachment(
|
||||||
db, user, dialog_id, attachment_id, body, request.app.state.s3, request.state.request_id
|
db, user, dialog_id, attachment_id, body, request.app.state.s3, context
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -845,7 +903,7 @@ async def attachment_download(
|
|||||||
db: Session,
|
db: Session,
|
||||||
user: UserDep,
|
user: UserDep,
|
||||||
settings: SnapshotDep,
|
settings: SnapshotDep,
|
||||||
x_ux_session_id: Annotated[str | None, Header()] = None,
|
context: UserAuditContextDep,
|
||||||
):
|
):
|
||||||
await enforce_limit(
|
await enforce_limit(
|
||||||
request,
|
request,
|
||||||
@@ -871,11 +929,11 @@ async def attachment_download(
|
|||||||
db.add(
|
db.add(
|
||||||
audit(
|
audit(
|
||||||
"attachment.download_url_issued",
|
"attachment.download_url_issued",
|
||||||
request.state.request_id,
|
context,
|
||||||
user.id,
|
user.id,
|
||||||
"attachment",
|
"attachment",
|
||||||
item.id,
|
item.id,
|
||||||
ux_id(x_ux_session_id),
|
metadata={"expires_in_seconds": 300},
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
@@ -895,7 +953,7 @@ async def inbox(event: OpenLinesInbox, request: Request, db: Session, settings:
|
|||||||
body, status = await apply_inbox(
|
body, status = await apply_inbox(
|
||||||
db,
|
db,
|
||||||
event,
|
event,
|
||||||
request.state.request_id,
|
audit_context(request),
|
||||||
settings,
|
settings,
|
||||||
request.app.state.s3,
|
request.app.state.s3,
|
||||||
request.app.state.http,
|
request.app.state.http,
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from pathlib import PurePath
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from sqlalchemy import func, select
|
from sqlalchemy import case, func, select
|
||||||
from sqlalchemy.dialects.postgresql import insert
|
from sqlalchemy.dialects.postgresql import insert
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
@@ -106,6 +106,24 @@ class SettingsSnapshot:
|
|||||||
return int(amount), windows[period]
|
return int(amount), windows[period]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class AuditContext:
|
||||||
|
request_id: str
|
||||||
|
trace_id: str
|
||||||
|
ux_session_id: uuid.UUID | None
|
||||||
|
user_agent_hash: str | None
|
||||||
|
client_ip: str | None
|
||||||
|
|
||||||
|
def with_ux_session(self, ux_session_id: uuid.UUID) -> "AuditContext":
|
||||||
|
return AuditContext(
|
||||||
|
request_id=self.request_id,
|
||||||
|
trace_id=self.trace_id,
|
||||||
|
ux_session_id=ux_session_id,
|
||||||
|
user_agent_hash=self.user_agent_hash,
|
||||||
|
client_ip=self.client_ip,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def load_settings(session: AsyncSession) -> SettingsSnapshot:
|
async def load_settings(session: AsyncSession) -> SettingsSnapshot:
|
||||||
rows = (
|
rows = (
|
||||||
await session.execute(select(AppSetting).where(AppSetting.record_status == "A"))
|
await session.execute(select(AppSetting).where(AppSetting.record_status == "A"))
|
||||||
@@ -138,26 +156,45 @@ async def resolve_user(session: AsyncSession, principal: Principal) -> UserIdent
|
|||||||
|
|
||||||
def audit(
|
def audit(
|
||||||
event_type: str,
|
event_type: str,
|
||||||
request_id: str,
|
context: AuditContext,
|
||||||
user_id: uuid.UUID | None,
|
user_id: uuid.UUID | None,
|
||||||
resource_type: str | None = None,
|
resource_type: str | None = None,
|
||||||
resource_id: uuid.UUID | None = None,
|
resource_id: uuid.UUID | None = None,
|
||||||
ux_session_id: uuid.UUID | None = None,
|
|
||||||
metadata: dict[str, Any] | None = None,
|
metadata: dict[str, Any] | None = None,
|
||||||
|
outcome: str = "success",
|
||||||
) -> AuditEvent:
|
) -> AuditEvent:
|
||||||
return AuditEvent(
|
return AuditEvent(
|
||||||
event_type=event_type,
|
event_type=event_type,
|
||||||
actor_type="user" if user_id else "service",
|
actor_type="user" if user_id else "service",
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
ux_session_id=ux_session_id,
|
ux_session_id=context.ux_session_id,
|
||||||
request_id=request_id,
|
request_id=context.request_id,
|
||||||
|
trace_id=context.trace_id,
|
||||||
resource_type=resource_type,
|
resource_type=resource_type,
|
||||||
resource_id=resource_id,
|
resource_id=resource_id,
|
||||||
outcome="success",
|
user_agent_hash=context.user_agent_hash,
|
||||||
|
outcome=outcome,
|
||||||
metadata_json=metadata or {},
|
metadata_json=metadata or {},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def device_snapshot(device: Any) -> dict[str, str | None]:
|
||||||
|
return {
|
||||||
|
"platform": device.platform,
|
||||||
|
"app_version": device.app_version,
|
||||||
|
"device_id_hash": (
|
||||||
|
hashlib.sha256(device.device_id.encode()).hexdigest() if device.device_id else None
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def consent_versions(consents: Any) -> dict[str, str]:
|
||||||
|
return {
|
||||||
|
name: getattr(consents, name).version
|
||||||
|
for name in ("personal_data", "user_agreement", "marketing")
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def validate_consents(consents: Any, snapshot: SettingsSnapshot) -> None:
|
def validate_consents(consents: Any, snapshot: SettingsSnapshot) -> None:
|
||||||
for name in ("personal_data", "user_agreement", "marketing"):
|
for name in ("personal_data", "user_agreement", "marketing"):
|
||||||
choice = getattr(consents, name)
|
choice = getattr(consents, name)
|
||||||
@@ -174,12 +211,13 @@ async def bootstrap(
|
|||||||
principal: Principal,
|
principal: Principal,
|
||||||
body: BootstrapRequest,
|
body: BootstrapRequest,
|
||||||
snapshot: SettingsSnapshot,
|
snapshot: SettingsSnapshot,
|
||||||
request_id: str,
|
context: AuditContext,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
if not principal.phone_number:
|
if not principal.phone_number:
|
||||||
raise DomainError("phone_claim_missing", 400, "Verified phone claim is missing")
|
raise DomainError("phone_claim_missing", 400, "Verified phone claim is missing")
|
||||||
validate_consents(body.consents, snapshot)
|
validate_consents(body.consents, snapshot)
|
||||||
now = datetime.now(UTC)
|
now = datetime.now(UTC)
|
||||||
|
device = device_snapshot(body.device)
|
||||||
statement = (
|
statement = (
|
||||||
insert(UserIdentity)
|
insert(UserIdentity)
|
||||||
.values(
|
.values(
|
||||||
@@ -211,6 +249,9 @@ async def bootstrap(
|
|||||||
document_version=choice.version,
|
document_version=choice.version,
|
||||||
accepted=choice.accepted,
|
accepted=choice.accepted,
|
||||||
accepted_at=now,
|
accepted_at=now,
|
||||||
|
client_ip=context.client_ip,
|
||||||
|
user_agent_hash=context.user_agent_hash,
|
||||||
|
device_json=device,
|
||||||
)
|
)
|
||||||
.on_conflict_do_nothing(
|
.on_conflict_do_nothing(
|
||||||
index_elements=[
|
index_elements=[
|
||||||
@@ -220,7 +261,18 @@ async def bootstrap(
|
|||||||
]
|
]
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
session.add(audit("auth.bootstrap", request_id, user_id))
|
session.add(
|
||||||
|
audit(
|
||||||
|
"auth.bootstrap",
|
||||||
|
context,
|
||||||
|
user_id,
|
||||||
|
metadata={
|
||||||
|
"consent_versions": consent_versions(body.consents),
|
||||||
|
"platform": body.device.platform,
|
||||||
|
"app_version": body.device.app_version,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
return {"user_id": user_id, "profile_ready": True}
|
return {"user_id": user_id, "profile_ready": True}
|
||||||
|
|
||||||
@@ -230,42 +282,83 @@ async def record_consents(
|
|||||||
user: UserIdentity,
|
user: UserIdentity,
|
||||||
body: ConsentsRequest,
|
body: ConsentsRequest,
|
||||||
snapshot: SettingsSnapshot,
|
snapshot: SettingsSnapshot,
|
||||||
request_id: str,
|
context: AuditContext,
|
||||||
ux_session_id: uuid.UUID | None,
|
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
validate_consents(body.consents, snapshot)
|
validate_consents(body.consents, snapshot)
|
||||||
|
if context.ux_session_id is None:
|
||||||
|
raise DomainError("validation_error", 400, "X-Ux-Session-Id is required")
|
||||||
|
ux_session = (
|
||||||
|
await session.execute(
|
||||||
|
select(UxSession).where(
|
||||||
|
UxSession.id == context.ux_session_id,
|
||||||
|
UxSession.user_id == user.id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if ux_session is None:
|
||||||
|
raise DomainError("validation_error", 400, "X-Ux-Session-Id is invalid")
|
||||||
|
device = {
|
||||||
|
"platform": ux_session.platform,
|
||||||
|
"app_version": ux_session.app_version,
|
||||||
|
"device_id_hash": ux_session.device_id_hash,
|
||||||
|
}
|
||||||
now = datetime.now(UTC)
|
now = datetime.now(UTC)
|
||||||
versions: dict[str, str] = {}
|
versions: dict[str, str] = {}
|
||||||
for consent_type in ("personal_data", "user_agreement", "marketing"):
|
for consent_type in ("personal_data", "user_agreement", "marketing"):
|
||||||
choice = getattr(body.consents, consent_type)
|
choice = getattr(body.consents, consent_type)
|
||||||
versions[consent_type] = choice.version
|
versions[consent_type] = choice.version
|
||||||
await session.execute(
|
statement = insert(UserConsent).values(
|
||||||
insert(UserConsent)
|
|
||||||
.values(
|
|
||||||
id=uuid.uuid4(),
|
id=uuid.uuid4(),
|
||||||
user_id=user.id,
|
user_id=user.id,
|
||||||
ux_session_id=ux_session_id,
|
ux_session_id=context.ux_session_id,
|
||||||
consent_type=consent_type,
|
consent_type=consent_type,
|
||||||
document_version=choice.version,
|
document_version=choice.version,
|
||||||
accepted=choice.accepted,
|
accepted=choice.accepted,
|
||||||
accepted_at=now,
|
accepted_at=now,
|
||||||
|
client_ip=context.client_ip,
|
||||||
|
user_agent_hash=context.user_agent_hash,
|
||||||
|
device_json=device,
|
||||||
|
)
|
||||||
|
await session.execute(
|
||||||
|
statement.on_conflict_do_update(
|
||||||
|
index_elements=[
|
||||||
|
UserConsent.user_id,
|
||||||
|
UserConsent.consent_type,
|
||||||
|
UserConsent.document_version,
|
||||||
|
],
|
||||||
|
set_={
|
||||||
|
"ux_session_id": statement.excluded.ux_session_id,
|
||||||
|
"client_ip": func.coalesce(
|
||||||
|
UserConsent.client_ip, statement.excluded.client_ip
|
||||||
|
),
|
||||||
|
"user_agent_hash": func.coalesce(
|
||||||
|
UserConsent.user_agent_hash,
|
||||||
|
statement.excluded.user_agent_hash,
|
||||||
|
),
|
||||||
|
"device_json": case(
|
||||||
|
(UserConsent.device_json == {}, statement.excluded.device_json),
|
||||||
|
else_=UserConsent.device_json,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
session.add(
|
||||||
|
audit(
|
||||||
|
"consent.recorded",
|
||||||
|
context,
|
||||||
|
user.id,
|
||||||
|
metadata={"consent_versions": versions},
|
||||||
)
|
)
|
||||||
.on_conflict_do_nothing()
|
|
||||||
)
|
)
|
||||||
session.add(audit("consent.recorded", request_id, user.id, ux_session_id=ux_session_id))
|
|
||||||
await session.commit()
|
await session.commit()
|
||||||
return {"recorded_at": now, "versions": versions}
|
return {"recorded_at": now, "versions": versions}
|
||||||
|
|
||||||
|
|
||||||
async def start_session(
|
async def start_session(
|
||||||
session: AsyncSession, user: UserIdentity, body: SessionStartRequest, request_id: str
|
session: AsyncSession, user: UserIdentity, body: SessionStartRequest, context: AuditContext
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
now, session_id = datetime.now(UTC), uuid.uuid4()
|
now, session_id = datetime.now(UTC), uuid.uuid4()
|
||||||
device_hash = (
|
device = device_snapshot(body.device)
|
||||||
hashlib.sha256(body.device.device_id.encode()).hexdigest()
|
|
||||||
if body.device.device_id
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
session.add(
|
session.add(
|
||||||
UxSession(
|
UxSession(
|
||||||
id=session_id,
|
id=session_id,
|
||||||
@@ -273,11 +366,22 @@ async def start_session(
|
|||||||
start_reason=body.start_reason,
|
start_reason=body.start_reason,
|
||||||
platform=body.device.platform,
|
platform=body.device.platform,
|
||||||
app_version=body.device.app_version,
|
app_version=body.device.app_version,
|
||||||
device_id_hash=device_hash,
|
device_id_hash=device["device_id_hash"],
|
||||||
started_at=now,
|
started_at=now,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
session.add(audit("session_start", request_id, user.id, ux_session_id=session_id))
|
session.add(
|
||||||
|
audit(
|
||||||
|
"session_start",
|
||||||
|
context.with_ux_session(session_id),
|
||||||
|
user.id,
|
||||||
|
metadata={
|
||||||
|
"start_reason": body.start_reason,
|
||||||
|
"platform": body.device.platform,
|
||||||
|
"app_version": body.device.app_version,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
return {"ux_session_id": session_id, "started_at": now}
|
return {"ux_session_id": session_id, "started_at": now}
|
||||||
|
|
||||||
@@ -407,7 +511,7 @@ async def owned_dialog(session: AsyncSession, user_id: uuid.UUID, dialog_id: uui
|
|||||||
|
|
||||||
|
|
||||||
async def create_dialog(
|
async def create_dialog(
|
||||||
session: AsyncSession, user: UserIdentity, request_id: str, idempotency_key: str
|
session: AsyncSession, user: UserIdentity, context: AuditContext, idempotency_key: str
|
||||||
) -> tuple[dict[str, Any], int]:
|
) -> tuple[dict[str, Any], int]:
|
||||||
scope = "dialogs.create"
|
scope = "dialogs.create"
|
||||||
fingerprint = hashlib.sha256(f"POST:/api/v1/dialogs:{user.id}".encode()).hexdigest()
|
fingerprint = hashlib.sha256(f"POST:/api/v1/dialogs:{user.id}".encode()).hexdigest()
|
||||||
@@ -454,7 +558,16 @@ async def create_dialog(
|
|||||||
return result, 200
|
return result, 200
|
||||||
dialog = Dialog(id=uuid.uuid4(), user_id=user.id, status="open")
|
dialog = Dialog(id=uuid.uuid4(), user_id=user.id, status="open")
|
||||||
session.add(dialog)
|
session.add(dialog)
|
||||||
session.add(audit("dialog.created", request_id, user.id, "dialog", dialog.id))
|
session.add(
|
||||||
|
audit(
|
||||||
|
"dialog.created",
|
||||||
|
context,
|
||||||
|
user.id,
|
||||||
|
"dialog",
|
||||||
|
dialog.id,
|
||||||
|
metadata={"status": dialog.status},
|
||||||
|
)
|
||||||
|
)
|
||||||
result = dialog_dto(dialog)
|
result = dialog_dto(dialog)
|
||||||
session.add(
|
session.add(
|
||||||
IdempotencyRecord(
|
IdempotencyRecord(
|
||||||
@@ -482,7 +595,7 @@ async def init_attachment(
|
|||||||
body: AttachmentInitRequest,
|
body: AttachmentInitRequest,
|
||||||
snapshot: SettingsSnapshot,
|
snapshot: SettingsSnapshot,
|
||||||
s3: S3Client,
|
s3: S3Client,
|
||||||
request_id: str,
|
context: AuditContext,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
await owned_dialog(session, user.id, dialog_id)
|
await owned_dialog(session, user.id, dialog_id)
|
||||||
extension = PurePath(body.file_name).suffix.lower().lstrip(".")
|
extension = PurePath(body.file_name).suffix.lower().lstrip(".")
|
||||||
@@ -514,7 +627,14 @@ async def init_attachment(
|
|||||||
)
|
)
|
||||||
session.add(item)
|
session.add(item)
|
||||||
session.add(
|
session.add(
|
||||||
audit("attachment.upload_initialized", request_id, user.id, "attachment", attachment_id)
|
audit(
|
||||||
|
"attachment.upload_initialized",
|
||||||
|
context,
|
||||||
|
user.id,
|
||||||
|
"attachment",
|
||||||
|
attachment_id,
|
||||||
|
metadata={"mime_type": body.mime_type, "size_bytes": body.size_bytes},
|
||||||
|
)
|
||||||
)
|
)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
url = await s3.presign_put(key, body.mime_type, ttl)
|
url = await s3.presign_put(key, body.mime_type, ttl)
|
||||||
@@ -533,7 +653,7 @@ async def complete_attachment(
|
|||||||
attachment_id: uuid.UUID,
|
attachment_id: uuid.UUID,
|
||||||
body: AttachmentCompleteRequest,
|
body: AttachmentCompleteRequest,
|
||||||
s3: S3Client,
|
s3: S3Client,
|
||||||
request_id: str,
|
context: AuditContext,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
item = (
|
item = (
|
||||||
await session.execute(
|
await session.execute(
|
||||||
@@ -560,7 +680,16 @@ async def complete_attachment(
|
|||||||
raise DomainError("attachment_checksum_mismatch", 400, "Uploaded metadata does not match")
|
raise DomainError("attachment_checksum_mismatch", 400, "Uploaded metadata does not match")
|
||||||
item.checksum_sha256 = checksum
|
item.checksum_sha256 = checksum
|
||||||
item.completed_at = datetime.now(UTC)
|
item.completed_at = datetime.now(UTC)
|
||||||
session.add(audit("attachment.upload_completed", request_id, user.id, "attachment", item.id))
|
session.add(
|
||||||
|
audit(
|
||||||
|
"attachment.upload_completed",
|
||||||
|
context,
|
||||||
|
user.id,
|
||||||
|
"attachment",
|
||||||
|
item.id,
|
||||||
|
metadata={"mime_type": item.mime_type, "size_bytes": item.size_bytes},
|
||||||
|
)
|
||||||
|
)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
return attachment_dto(item)
|
return attachment_dto(item)
|
||||||
|
|
||||||
@@ -583,7 +712,7 @@ async def send_message(
|
|||||||
dialog_id: uuid.UUID,
|
dialog_id: uuid.UUID,
|
||||||
body: MessageRequest,
|
body: MessageRequest,
|
||||||
idem_key: str,
|
idem_key: str,
|
||||||
request_id: str,
|
context: AuditContext,
|
||||||
settings: Settings,
|
settings: Settings,
|
||||||
safety: SafetyClient,
|
safety: SafetyClient,
|
||||||
openlines: OpenLinesClient,
|
openlines: OpenLinesClient,
|
||||||
@@ -672,7 +801,16 @@ async def send_message(
|
|||||||
session.add(message)
|
session.add(message)
|
||||||
if attachment:
|
if attachment:
|
||||||
attachment.message_id = message_id
|
attachment.message_id = message_id
|
||||||
session.add(audit("message.submitted", request_id, user.id, "message", message_id))
|
session.add(
|
||||||
|
audit(
|
||||||
|
"message.submitted",
|
||||||
|
context,
|
||||||
|
user.id,
|
||||||
|
"message",
|
||||||
|
message_id,
|
||||||
|
metadata={"content_kind": body.content_kind, "delivery_status": "processing"},
|
||||||
|
)
|
||||||
|
)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await publish_message(fanout, message, settings, [attachment] if attachment else [])
|
await publish_message(fanout, message, settings, [attachment] if attachment else [])
|
||||||
payload: dict[str, Any] = {"message_id": str(message_id), "content_kind": kind, "text": text}
|
payload: dict[str, Any] = {"message_id": str(message_id), "content_kind": kind, "text": text}
|
||||||
@@ -684,8 +822,9 @@ async def send_message(
|
|||||||
"mime_type": attachment.mime_type,
|
"mime_type": attachment.mime_type,
|
||||||
"size_bytes": attachment.size_bytes,
|
"size_bytes": attachment.size_bytes,
|
||||||
}
|
}
|
||||||
|
outbox: DeliveryOutbox | None = None
|
||||||
try:
|
try:
|
||||||
verdict = await safety.check(payload, request_id)
|
verdict = await safety.check(payload, context.request_id)
|
||||||
if verdict["_status"] == 203:
|
if verdict["_status"] == 203:
|
||||||
task_id = verdict["task_id"]
|
task_id = verdict["task_id"]
|
||||||
task = SafetyTask(
|
task = SafetyTask(
|
||||||
@@ -703,7 +842,7 @@ async def send_message(
|
|||||||
deadline = time_monotonic() + settings.message_safety_task_poll_max_sec
|
deadline = time_monotonic() + settings.message_safety_task_poll_max_sec
|
||||||
while time_monotonic() < deadline:
|
while time_monotonic() < deadline:
|
||||||
await sleep(settings.message_safety_task_poll_interval_sec)
|
await sleep(settings.message_safety_task_poll_interval_sec)
|
||||||
verdict = await safety.poll(task_id, request_id)
|
verdict = await safety.poll(task_id, context.request_id)
|
||||||
if verdict["_status"] != 203:
|
if verdict["_status"] != 203:
|
||||||
break
|
break
|
||||||
else:
|
else:
|
||||||
@@ -719,7 +858,20 @@ async def send_message(
|
|||||||
if attachment and attachment.quarantine_object_key:
|
if attachment and attachment.quarantine_object_key:
|
||||||
attachment.scan_status = "infected"
|
attachment.scan_status = "infected"
|
||||||
await s3.delete_quarantine(attachment.quarantine_object_key)
|
await s3.delete_quarantine(attachment.quarantine_object_key)
|
||||||
session.add(audit("message.blocked", request_id, user.id, "message", message.id))
|
session.add(
|
||||||
|
audit(
|
||||||
|
"message.blocked",
|
||||||
|
context,
|
||||||
|
user.id,
|
||||||
|
"message",
|
||||||
|
message.id,
|
||||||
|
metadata={
|
||||||
|
"content_kind": message.content_kind,
|
||||||
|
"safety_status": message.safety_status,
|
||||||
|
"delivery_status": message.delivery_status,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
idem.status = "completed"
|
idem.status = "completed"
|
||||||
idem.response_status = 422
|
idem.response_status = 422
|
||||||
idem.response_body_json = {
|
idem.response_body_json = {
|
||||||
@@ -765,18 +917,35 @@ async def send_message(
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
next_attempt_at=datetime.now(UTC),
|
# Keep the row recoverable after a process crash, but do not let the
|
||||||
|
# delivery worker race the synchronous first attempt.
|
||||||
|
next_attempt_at=datetime.now(UTC)
|
||||||
|
+ timedelta(seconds=settings.bitrix_local_app_http_timeout_sec + 5),
|
||||||
)
|
)
|
||||||
session.add(outbox)
|
session.add(outbox)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await publish_message_status(fanout, message, settings)
|
await publish_message_status(fanout, message, settings)
|
||||||
delivery_payload = await fresh_openlines_payload(outbox.payload_json, s3)
|
delivery_payload = await fresh_openlines_payload(outbox.payload_json, s3)
|
||||||
await openlines.send(message.id, delivery_payload, request_id)
|
await openlines.send(message.id, delivery_payload, context.request_id)
|
||||||
message.delivery_status = "delivered"
|
message.delivery_status = "delivered"
|
||||||
dialog.status = "waiting_for_company"
|
dialog.status = "waiting_for_company"
|
||||||
dialog.last_message_at = datetime.now(UTC)
|
dialog.last_message_at = datetime.now(UTC)
|
||||||
outbox.status = "delivered"
|
outbox.status = "delivered"
|
||||||
session.add(audit("message.delivered", request_id, user.id, "message", message.id))
|
session.add(
|
||||||
|
audit(
|
||||||
|
"message.delivered",
|
||||||
|
context,
|
||||||
|
user.id,
|
||||||
|
"message",
|
||||||
|
message.id,
|
||||||
|
metadata={
|
||||||
|
"content_kind": message.content_kind,
|
||||||
|
"safety_status": message.safety_status,
|
||||||
|
"delivery_status": message.delivery_status,
|
||||||
|
},
|
||||||
|
outcome="blocked",
|
||||||
|
)
|
||||||
|
)
|
||||||
result = message_dto(message, [attachment] if attachment else [])
|
result = message_dto(message, [attachment] if attachment else [])
|
||||||
idem.status = "completed"
|
idem.status = "completed"
|
||||||
idem.response_status = 201
|
idem.response_status = 201
|
||||||
@@ -789,7 +958,31 @@ async def send_message(
|
|||||||
raise
|
raise
|
||||||
except DependencyFailure as exc:
|
except DependencyFailure as exc:
|
||||||
message.delivery_status = "failed"
|
message.delivery_status = "failed"
|
||||||
session.add(audit("message.failed", request_id, user.id, "message", message.id))
|
if outbox is not None:
|
||||||
|
outbox.attempt_count += 1
|
||||||
|
outbox.status = "retry"
|
||||||
|
outbox.next_attempt_at = datetime.now(UTC) + timedelta(
|
||||||
|
seconds=min(3600, 2**outbox.attempt_count)
|
||||||
|
)
|
||||||
|
outbox.last_error_code = (
|
||||||
|
"dependency_timeout" if exc.timeout else "dependency_unavailable"
|
||||||
|
)
|
||||||
|
outbox.locked_at = None
|
||||||
|
outbox.locked_by = None
|
||||||
|
session.add(
|
||||||
|
audit(
|
||||||
|
"message.failed",
|
||||||
|
context,
|
||||||
|
user.id,
|
||||||
|
"message",
|
||||||
|
message.id,
|
||||||
|
metadata={
|
||||||
|
"content_kind": message.content_kind,
|
||||||
|
"delivery_status": message.delivery_status,
|
||||||
|
},
|
||||||
|
outcome="failed",
|
||||||
|
)
|
||||||
|
)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await publish_message_status(fanout, message, settings)
|
await publish_message_status(fanout, message, settings)
|
||||||
raise DomainError(
|
raise DomainError(
|
||||||
@@ -802,7 +995,7 @@ async def send_message(
|
|||||||
async def apply_inbox(
|
async def apply_inbox(
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
event: OpenLinesInbox,
|
event: OpenLinesInbox,
|
||||||
request_id: str,
|
context: AuditContext,
|
||||||
snapshot: SettingsSnapshot,
|
snapshot: SettingsSnapshot,
|
||||||
s3: S3Client,
|
s3: S3Client,
|
||||||
http: httpx.AsyncClient,
|
http: httpx.AsyncClient,
|
||||||
@@ -910,7 +1103,16 @@ async def apply_inbox(
|
|||||||
dialog.status = "waiting_for_client"
|
dialog.status = "waiting_for_client"
|
||||||
dialog.last_message_at = event.occurred_at
|
dialog.last_message_at = event.occurred_at
|
||||||
receipt.status = "applied"
|
receipt.status = "applied"
|
||||||
session.add(audit("openlines.inbox_applied", request_id, dialog.user_id, "dialog", dialog.id))
|
session.add(
|
||||||
|
audit(
|
||||||
|
"openlines.inbox_applied",
|
||||||
|
context,
|
||||||
|
None,
|
||||||
|
"dialog",
|
||||||
|
dialog.id,
|
||||||
|
metadata={"event_type": event.event_type},
|
||||||
|
)
|
||||||
|
)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
if message is not None:
|
if message is not None:
|
||||||
await publish_message(fanout, message, settings, [attachment] if attachment else [])
|
await publish_message(fanout, message, settings, [attachment] if attachment else [])
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ class Settings(BaseSettings):
|
|||||||
bitrix_local_app_internal_token: SecretStr = Field(alias="BITRIX_LOCAL_APP_INTERNAL_TOKEN")
|
bitrix_local_app_internal_token: SecretStr = Field(alias="BITRIX_LOCAL_APP_INTERNAL_TOKEN")
|
||||||
bitrix_api_inbox_token: SecretStr = Field(alias="BITRIX_API_INBOX_TOKEN")
|
bitrix_api_inbox_token: SecretStr = Field(alias="BITRIX_API_INBOX_TOKEN")
|
||||||
bitrix_local_app_http_timeout_sec: float = Field(
|
bitrix_local_app_http_timeout_sec: float = Field(
|
||||||
default=10, alias="BITRIX_LOCAL_APP_HTTP_TIMEOUT_SEC"
|
default=20, alias="BITRIX_LOCAL_APP_HTTP_TIMEOUT_SEC"
|
||||||
)
|
)
|
||||||
bitrix_local_app_circuit_failure_threshold: int = Field(
|
bitrix_local_app_circuit_failure_threshold: int = Field(
|
||||||
default=5, alias="BITRIX_LOCAL_APP_CIRCUIT_FAILURE_THRESHOLD"
|
default=5, alias="BITRIX_LOCAL_APP_CIRCUIT_FAILURE_THRESHOLD"
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ services:
|
|||||||
BITRIX_LOCAL_APP_BASE_URL: ${BITRIX_LOCAL_APP_BASE_URL}
|
BITRIX_LOCAL_APP_BASE_URL: ${BITRIX_LOCAL_APP_BASE_URL}
|
||||||
BITRIX_LOCAL_APP_INTERNAL_TOKEN: ${BITRIX_LOCAL_APP_INTERNAL_TOKEN}
|
BITRIX_LOCAL_APP_INTERNAL_TOKEN: ${BITRIX_LOCAL_APP_INTERNAL_TOKEN}
|
||||||
BITRIX_API_INBOX_TOKEN: ${BITRIX_API_INBOX_TOKEN}
|
BITRIX_API_INBOX_TOKEN: ${BITRIX_API_INBOX_TOKEN}
|
||||||
BITRIX_LOCAL_APP_HTTP_TIMEOUT_SEC: ${BITRIX_LOCAL_APP_HTTP_TIMEOUT_SEC:-10}
|
BITRIX_LOCAL_APP_HTTP_TIMEOUT_SEC: ${BITRIX_LOCAL_APP_HTTP_TIMEOUT_SEC:-20}
|
||||||
BITRIX_LOCAL_APP_CIRCUIT_FAILURE_THRESHOLD: ${BITRIX_LOCAL_APP_CIRCUIT_FAILURE_THRESHOLD:-5}
|
BITRIX_LOCAL_APP_CIRCUIT_FAILURE_THRESHOLD: ${BITRIX_LOCAL_APP_CIRCUIT_FAILURE_THRESHOLD:-5}
|
||||||
BITRIX_LOCAL_APP_CIRCUIT_OPEN_SEC: ${BITRIX_LOCAL_APP_CIRCUIT_OPEN_SEC:-30}
|
BITRIX_LOCAL_APP_CIRCUIT_OPEN_SEC: ${BITRIX_LOCAL_APP_CIRCUIT_OPEN_SEC:-30}
|
||||||
KEYCLOAK_SETTINGS_BRIDGE_TOKEN: ${KEYCLOAK_SETTINGS_BRIDGE_TOKEN}
|
KEYCLOAK_SETTINGS_BRIDGE_TOKEN: ${KEYCLOAK_SETTINGS_BRIDGE_TOKEN}
|
||||||
|
|||||||
@@ -0,0 +1,154 @@
|
|||||||
|
import hashlib
|
||||||
|
import uuid
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from starlette.requests import Request
|
||||||
|
|
||||||
|
from app.main import (
|
||||||
|
app,
|
||||||
|
client_ip,
|
||||||
|
request_trace_id,
|
||||||
|
required_user_audit_context,
|
||||||
|
user_agent_hash,
|
||||||
|
)
|
||||||
|
from app.schemas import Device
|
||||||
|
from app.services import AuditContext, DomainError, audit, device_snapshot
|
||||||
|
|
||||||
|
|
||||||
|
def request(
|
||||||
|
*,
|
||||||
|
peer: str = "172.18.0.5",
|
||||||
|
forwarded: str | None = None,
|
||||||
|
traceparent: str | None = None,
|
||||||
|
user_agent: str | None = None,
|
||||||
|
ux_session_id: str | None = None,
|
||||||
|
trusted: str = "172.16.0.0/12",
|
||||||
|
) -> Request:
|
||||||
|
headers: list[tuple[bytes, bytes]] = []
|
||||||
|
for name, value in (
|
||||||
|
("x-forwarded-for", forwarded),
|
||||||
|
("traceparent", traceparent),
|
||||||
|
("user-agent", user_agent),
|
||||||
|
("x-ux-session-id", ux_session_id),
|
||||||
|
):
|
||||||
|
if value is not None:
|
||||||
|
headers.append((name.encode(), value.encode()))
|
||||||
|
settings = SimpleNamespace(trusted_proxy_cidrs=trusted)
|
||||||
|
app = SimpleNamespace(state=SimpleNamespace(settings=settings))
|
||||||
|
return Request(
|
||||||
|
{
|
||||||
|
"type": "http",
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/",
|
||||||
|
"headers": headers,
|
||||||
|
"client": (peer, 12345),
|
||||||
|
"server": ("test", 443),
|
||||||
|
"scheme": "https",
|
||||||
|
"query_string": b"",
|
||||||
|
"app": app,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_client_ip_only_trusts_forwarded_header_from_configured_proxy() -> None:
|
||||||
|
assert client_ip(request(forwarded="203.0.113.10")) == "203.0.113.10"
|
||||||
|
assert (
|
||||||
|
client_ip(request(peer="198.51.100.7", forwarded="203.0.113.10"))
|
||||||
|
== "198.51.100.7"
|
||||||
|
)
|
||||||
|
assert client_ip(request(peer="not-an-ip")) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_trace_id_uses_valid_w3c_header_and_generates_fallback() -> None:
|
||||||
|
trace_id = "1" * 32
|
||||||
|
assert request_trace_id(request(traceparent=f"00-{trace_id}-{'2' * 16}-01")) == trace_id
|
||||||
|
fallback = request_trace_id(request(traceparent="invalid"))
|
||||||
|
assert len(fallback) == 32
|
||||||
|
int(fallback, 16)
|
||||||
|
|
||||||
|
|
||||||
|
def test_user_agent_and_device_are_hashed_without_raw_identifiers() -> None:
|
||||||
|
agent = "Example Browser/1.0"
|
||||||
|
assert user_agent_hash(request(user_agent=agent)) == hashlib.sha256(agent.encode()).hexdigest()
|
||||||
|
snapshot = device_snapshot(
|
||||||
|
Device(platform="web", app_version="1.2.3", device_id="raw-device-id")
|
||||||
|
)
|
||||||
|
assert snapshot == {
|
||||||
|
"platform": "web",
|
||||||
|
"app_version": "1.2.3",
|
||||||
|
"device_id_hash": hashlib.sha256(b"raw-device-id").hexdigest(),
|
||||||
|
}
|
||||||
|
assert "raw-device-id" not in str(snapshot)
|
||||||
|
|
||||||
|
|
||||||
|
def test_audit_copies_request_context_and_bounded_metadata() -> None:
|
||||||
|
session_id = uuid.uuid4()
|
||||||
|
user_id = uuid.uuid4()
|
||||||
|
context = AuditContext(
|
||||||
|
request_id=str(uuid.uuid4()),
|
||||||
|
trace_id="a" * 32,
|
||||||
|
ux_session_id=session_id,
|
||||||
|
user_agent_hash="b" * 64,
|
||||||
|
client_ip="203.0.113.10",
|
||||||
|
)
|
||||||
|
|
||||||
|
event = audit(
|
||||||
|
"dialog.created",
|
||||||
|
context,
|
||||||
|
user_id,
|
||||||
|
"dialog",
|
||||||
|
uuid.uuid4(),
|
||||||
|
metadata={"status": "open"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert event.user_id == user_id
|
||||||
|
assert event.ux_session_id == session_id
|
||||||
|
assert event.trace_id == "a" * 32
|
||||||
|
assert event.user_agent_hash == "b" * 64
|
||||||
|
assert event.metadata_json == {"status": "open"}
|
||||||
|
assert event.outcome == "success"
|
||||||
|
assert not hasattr(event, "ip")
|
||||||
|
|
||||||
|
failed = audit("message.failed", context, user_id, outcome="failed")
|
||||||
|
assert failed.outcome == "failed"
|
||||||
|
|
||||||
|
|
||||||
|
def test_post_session_routes_publish_required_ux_header_in_openapi() -> None:
|
||||||
|
operation = app.openapi()["paths"]["/api/v1/dialogs"]["post"]
|
||||||
|
ux_header = next(
|
||||||
|
parameter
|
||||||
|
for parameter in operation["parameters"]
|
||||||
|
if parameter["name"] == "X-Ux-Session-Id"
|
||||||
|
)
|
||||||
|
assert ux_header["in"] == "header"
|
||||||
|
assert ux_header["required"] is True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_user_audit_context_requires_existing_owned_session() -> None:
|
||||||
|
session_id = uuid.uuid4()
|
||||||
|
user = SimpleNamespace(id=uuid.uuid4())
|
||||||
|
incoming = request(
|
||||||
|
forwarded="203.0.113.10",
|
||||||
|
ux_session_id=str(session_id),
|
||||||
|
user_agent="Example Browser/1.0",
|
||||||
|
)
|
||||||
|
incoming.state.request_id = str(uuid.uuid4())
|
||||||
|
incoming.state.trace_id = "c" * 32
|
||||||
|
incoming.state.user_agent_hash = user_agent_hash(incoming)
|
||||||
|
|
||||||
|
class Db:
|
||||||
|
async def scalar(self, _query):
|
||||||
|
return session_id
|
||||||
|
|
||||||
|
context = await required_user_audit_context(incoming, Db(), user, str(session_id))
|
||||||
|
assert context.ux_session_id == session_id
|
||||||
|
assert context.client_ip == "203.0.113.10"
|
||||||
|
|
||||||
|
missing = request(forwarded="203.0.113.10")
|
||||||
|
missing.state.request_id = str(uuid.uuid4())
|
||||||
|
missing.state.trace_id = "d" * 32
|
||||||
|
missing.state.user_agent_hash = None
|
||||||
|
with pytest.raises(DomainError, match="X-Ux-Session-Id is required"):
|
||||||
|
await required_user_audit_context(missing, Db(), user, "")
|
||||||
@@ -159,7 +159,7 @@ def retry_delay(attempt: int, maximum: int) -> float:
|
|||||||
|
|
||||||
|
|
||||||
def safely_retryable(exc: Exception) -> bool:
|
def safely_retryable(exc: Exception) -> bool:
|
||||||
return isinstance(exc, httpx.ConnectError) or (
|
return isinstance(exc, (httpx.ConnectError, httpx.TimeoutException)) or (
|
||||||
isinstance(exc, httpx.HTTPStatusError) and exc.response.status_code in {429, 502, 503, 504}
|
isinstance(exc, httpx.HTTPStatusError) and exc.response.status_code in {429, 502, 503, 504}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -721,6 +721,7 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
|||||||
request_fingerprint=fp,
|
request_fingerprint=fp,
|
||||||
payload_json=body,
|
payload_json=body,
|
||||||
status="sending",
|
status="sending",
|
||||||
|
lease_until=now() + timedelta(seconds=cfg.bitrix_http_timeout_sec + 5),
|
||||||
)
|
)
|
||||||
session.add(row)
|
session.add(row)
|
||||||
try:
|
try:
|
||||||
@@ -775,6 +776,7 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
|||||||
else:
|
else:
|
||||||
current.status = "ambiguous"
|
current.status = "ambiguous"
|
||||||
current.last_error_code = "bitrix_delivery_ambiguous"
|
current.last_error_code = "bitrix_delivery_ambiguous"
|
||||||
|
current.lease_until = None
|
||||||
await session.commit()
|
await session.commit()
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
503,
|
503,
|
||||||
@@ -1101,7 +1103,12 @@ async def reconcile_setup(app: FastAPI) -> dict[str, bool]:
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
async def claim_one(session: AsyncSession, model, statuses: list[str]):
|
async def claim_one(
|
||||||
|
session: AsyncSession,
|
||||||
|
model,
|
||||||
|
statuses: list[str],
|
||||||
|
claimed_status: str | None = None,
|
||||||
|
):
|
||||||
row = await session.scalar(
|
row = await session.scalar(
|
||||||
select(model)
|
select(model)
|
||||||
.where(
|
.where(
|
||||||
@@ -1114,6 +1121,8 @@ async def claim_one(session: AsyncSession, model, statuses: list[str]):
|
|||||||
.limit(1)
|
.limit(1)
|
||||||
)
|
)
|
||||||
if row:
|
if row:
|
||||||
|
if claimed_status is not None:
|
||||||
|
row.status = claimed_status
|
||||||
row.lease_until = now() + timedelta(seconds=30)
|
row.lease_until = now() + timedelta(seconds=30)
|
||||||
row.attempt_count += 1
|
row.attempt_count += 1
|
||||||
await session.commit()
|
await session.commit()
|
||||||
@@ -1239,7 +1248,12 @@ async def process_ack(app: FastAPI) -> None:
|
|||||||
|
|
||||||
async def process_outbound(app: FastAPI) -> None:
|
async def process_outbound(app: FastAPI) -> None:
|
||||||
async with app.state.sessions() as session:
|
async with app.state.sessions() as session:
|
||||||
row = await claim_one(session, OutboundMessage, ["retry"])
|
row = await claim_one(
|
||||||
|
session,
|
||||||
|
OutboundMessage,
|
||||||
|
["retry"],
|
||||||
|
claimed_status="sending",
|
||||||
|
)
|
||||||
if not row:
|
if not row:
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ from app.main import (
|
|||||||
normalize_event,
|
normalize_event,
|
||||||
resolve_inbound_file_urls,
|
resolve_inbound_file_urls,
|
||||||
retry_delay,
|
retry_delay,
|
||||||
|
safely_retryable,
|
||||||
validate_portal,
|
validate_portal,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -206,6 +207,11 @@ def test_fingerprint_ignores_signed_query_and_portal_validation():
|
|||||||
assert 0 <= retry_delay(4, 300) <= 8
|
assert 0 <= retry_delay(4, 300) <= 8
|
||||||
|
|
||||||
|
|
||||||
|
def test_network_timeouts_are_retryable():
|
||||||
|
assert safely_retryable(httpx.ReadTimeout("Bitrix response timed out"))
|
||||||
|
assert safely_retryable(httpx.ConnectTimeout("Bitrix connection timed out"))
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_bitrix_call_refreshes_and_retries_once_after_401():
|
async def test_bitrix_call_refreshes_and_retries_once_after_401():
|
||||||
auth_values: list[str] = []
|
auth_values: list[str] = []
|
||||||
|
|||||||
@@ -43,9 +43,9 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
|
|||||||
if (minutes > 0) setIdleTimeoutMs(minutes * 60_000);
|
if (minutes > 0) setIdleTimeoutMs(minutes * 60_000);
|
||||||
}).catch(() => undefined);
|
}).catch(() => undefined);
|
||||||
void refreshTokens()
|
void refreshTokens()
|
||||||
.then(() => {
|
.then(async () => {
|
||||||
|
await authApi.startSession("cold_start");
|
||||||
setAuthStatus("authenticated");
|
setAuthStatus("authenticated");
|
||||||
void authApi.startSession("cold_start").catch(() => undefined);
|
|
||||||
})
|
})
|
||||||
.catch(() => setAuthStatus("guest"));
|
.catch(() => setAuthStatus("guest"));
|
||||||
}, [toGuest]);
|
}, [toGuest]);
|
||||||
|
|||||||
@@ -107,8 +107,10 @@ server {
|
|||||||
include /etc/nginx/snippets/proxy-keycloak.conf;
|
include /etc/nginx/snippets/proxy-keycloak.conf;
|
||||||
proxy_pass http://keycloak_upstream;
|
proxy_pass http://keycloak_upstream;
|
||||||
}
|
}
|
||||||
location ~ ^/auth/realms/[^/]+/protocol/openid-connect/3p-cookies/ {
|
location ^~ /auth/realms/ {
|
||||||
|
limit_req zone=auth burst=10;
|
||||||
include /etc/nginx/snippets/proxy-keycloak.conf;
|
include /etc/nginx/snippets/proxy-keycloak.conf;
|
||||||
|
proxy_read_timeout 60s;
|
||||||
proxy_pass http://keycloak_upstream;
|
proxy_pass http://keycloak_upstream;
|
||||||
}
|
}
|
||||||
location /auth/ {
|
location /auth/ {
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ class InfrastructureConfigTests(unittest.TestCase):
|
|||||||
self.assertIn("include /etc/nginx/snippets/proxy-keycloak.conf;", site)
|
self.assertIn("include /etc/nginx/snippets/proxy-keycloak.conf;", site)
|
||||||
self.assertIn("location = /auth/callback", site)
|
self.assertIn("location = /auth/callback", site)
|
||||||
self.assertIn("location ^~ /auth/resources/", site)
|
self.assertIn("location ^~ /auth/resources/", site)
|
||||||
self.assertIn("protocol/openid-connect/3p-cookies/", site)
|
self.assertIn("location ^~ /auth/realms/", site)
|
||||||
self.assertNotIn("security-headers.conf", proxy_keycloak)
|
self.assertNotIn("security-headers.conf", proxy_keycloak)
|
||||||
self.assertNotIn("X-Frame-Options", proxy_keycloak)
|
self.assertNotIn("X-Frame-Options", proxy_keycloak)
|
||||||
|
|
||||||
@@ -185,7 +185,15 @@ class InfrastructureConfigTests(unittest.TestCase):
|
|||||||
self.assertIn("public.digest(", initial)
|
self.assertIn("public.digest(", initial)
|
||||||
self.assertIn("public.digest(", fix)
|
self.assertIn("public.digest(", fix)
|
||||||
self.assertIn('down_revision: str | None = "0001_initial"', fix)
|
self.assertIn('down_revision: str | None = "0001_initial"', fix)
|
||||||
self.assertIn('revision != "0002_pgcrypto_digest"', main)
|
self.assertIn('revision != "0003_consent_audit"', main)
|
||||||
|
|
||||||
|
def test_consent_audit_migration_supports_existing_and_fresh_databases(self) -> None:
|
||||||
|
migration = (
|
||||||
|
ROOT / "api-backend/alembic/versions/0003_consent_device_audit_context.py"
|
||||||
|
).read_text(encoding="utf-8")
|
||||||
|
self.assertIn("ADD COLUMN IF NOT EXISTS device_json", migration)
|
||||||
|
self.assertIn("DROP COLUMN IF EXISTS ip", migration)
|
||||||
|
self.assertIn('down_revision: str | None = "0002_pgcrypto_digest"', migration)
|
||||||
|
|
||||||
def test_keycloak_management_health_and_bridge_environment(self) -> None:
|
def test_keycloak_management_health_and_bridge_environment(self) -> None:
|
||||||
standalone = (ROOT / "keycloak/docker-compose.yml").read_text(encoding="utf-8")
|
standalone = (ROOT / "keycloak/docker-compose.yml").read_text(encoding="utf-8")
|
||||||
|
|||||||
Reference in New Issue
Block a user