Реализованы сервисы ВМ2 - проверка сообщений и синхронизация с Б24 (деплой еще без перевода в боевой режим)
This commit is contained in:
@@ -39,6 +39,7 @@ han-notification-draft-cleanup-worker
|
||||
- `KEYCLOAK_PUBLIC_URL`, `KEYCLOAK_INTERNAL_URL`, `KEYCLOAK_REALM`,
|
||||
`KEYCLOAK_AUDIENCE`;
|
||||
- `MESSAGE_SAFETY_URL`, `MESSAGE_SAFETY_SERVICE_TOKEN`,
|
||||
`MESSAGE_SAFETY_CA_FILE`, `MESSAGE_SAFETY_API_PREFIX=/internal/safety/v2`,
|
||||
`MESSAGE_SAFETY_POST_TIMEOUT_SEC`, `MESSAGE_SAFETY_TASK_POLL_INTERVAL_SEC`,
|
||||
`MESSAGE_SAFETY_TASK_POLL_MAX_SEC`,
|
||||
`MESSAGE_SAFETY_CIRCUIT_FAILURE_THRESHOLD`, `MESSAGE_SAFETY_CIRCUIT_OPEN_SEC`;
|
||||
@@ -57,7 +58,9 @@ han-notification-draft-cleanup-worker
|
||||
|
||||
Токены генерируются `openssl rand -hex 32`. S3 read-only credentials Message Safety
|
||||
не передаются этому контейнеру. В production подключение PostgreSQL должно использовать
|
||||
TLS, а internal endpoints — быть доступны только из backend-сети.
|
||||
TLS. Target `MESSAGE_SAFETY_URL=https://processing.internal:8443`; certificate
|
||||
проверяется по internal CA, plaintext HTTP запрещён. Текущий Docker hostname
|
||||
`message-safety` относится только к legacy stub до cutover.
|
||||
|
||||
Smoke-сценарий `producer_test`: отправить `POST
|
||||
/internal/notifications/v1/notifications` с `Authorization: Bearer
|
||||
@@ -75,5 +78,7 @@ mypy app
|
||||
pytest
|
||||
```
|
||||
|
||||
`/health/live` проверяет процесс. `/health/ready` проверяет критические зависимости и
|
||||
возвращает `503`, если сервис не может безопасно обслуживать protected API.
|
||||
`/health/live` проверяет процесс. `/health/ready` проверяет критические
|
||||
зависимости read API. Remote Message Safety не выключает чтение/общую readiness:
|
||||
send endpoint отдельно проверяет требуемую capability и fail-closed возвращает
|
||||
`503`, если ВМ2 недоступна.
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
"""Expand module-07 queue and stage canonical Bitrix mapping.
|
||||
|
||||
Revision ID: 0011_module07_contract
|
||||
Revises: 0010_contact_map_dedup
|
||||
Create Date: 2026-08-06
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0011_module07_contract"
|
||||
down_revision: str | None = "0010_contact_map_dedup"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Expand first. Existing rows remain readable throughout the migration.
|
||||
op.execute(
|
||||
"""
|
||||
ALTER TABLE han_app.sync_queue
|
||||
ADD COLUMN IF NOT EXISTS locked_by varchar(128),
|
||||
ADD COLUMN IF NOT EXISTS locked_until timestamptz,
|
||||
ADD COLUMN IF NOT EXISTS lease_token uuid,
|
||||
ADD COLUMN IF NOT EXISTS last_error_code varchar(64),
|
||||
ADD COLUMN IF NOT EXISTS last_error_at timestamptz,
|
||||
ADD COLUMN IF NOT EXISTS completed_at timestamptz,
|
||||
ADD COLUMN IF NOT EXISTS cancel_reason varchar(255);
|
||||
|
||||
UPDATE han_app.sync_queue
|
||||
SET status = CASE status
|
||||
WHEN 'processing' THEN 'pending'
|
||||
WHEN 'failed' THEN 'retry_wait'
|
||||
ELSE status
|
||||
END
|
||||
WHERE status IN ('processing', 'failed');
|
||||
|
||||
ALTER TABLE han_app.sync_queue
|
||||
DROP CONSTRAINT IF EXISTS sync_queue_status_check;
|
||||
ALTER TABLE han_app.sync_queue
|
||||
ADD CONSTRAINT sync_queue_status_check CHECK (
|
||||
status IN ('pending','leased','processed','retry_wait','dead_letter','cancelled')
|
||||
) NOT VALID;
|
||||
ALTER TABLE han_app.sync_queue
|
||||
VALIDATE CONSTRAINT sync_queue_status_check;
|
||||
|
||||
ALTER TABLE han_app.sync_queue
|
||||
DROP CONSTRAINT IF EXISTS sync_queue_dedup_key_key;
|
||||
DROP INDEX IF EXISTS han_app.ix_sync_queue_status_next;
|
||||
CREATE INDEX IF NOT EXISTS ix_sync_queue_claim
|
||||
ON han_app.sync_queue(status, next_attempt_at, created_at);
|
||||
CREATE INDEX IF NOT EXISTS ix_sync_queue_expired_lease
|
||||
ON han_app.sync_queue(locked_until) WHERE status = 'leased';
|
||||
CREATE INDEX IF NOT EXISTS ix_sync_queue_entity_history
|
||||
ON han_app.sync_queue(entity_type, entity_id, created_at DESC);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_sync_queue_active_dedup
|
||||
ON han_app.sync_queue(dedup_key)
|
||||
WHERE status IN ('pending','leased','retry_wait');
|
||||
"""
|
||||
)
|
||||
|
||||
# The bitrix-sync migration owns the canonical schema. If that migration
|
||||
# already ran, copy and verify legacy rows here; otherwise its follow-up
|
||||
# migration performs the same copy. The legacy table remains until the
|
||||
# readers have switched and the contract migration is explicitly approved.
|
||||
op.execute(
|
||||
"""
|
||||
DO $$
|
||||
BEGIN
|
||||
IF to_regclass('bitrix_sync.entity_external_mapping') IS NOT NULL THEN
|
||||
INSERT INTO bitrix_sync.entity_external_mapping (
|
||||
id, entity_type, entity_id, external_system, external_entity_type,
|
||||
external_id, status, opened_at, created_at, updated_at
|
||||
)
|
||||
SELECT id, entity_type, entity_id, 'bitrix24', 'contact',
|
||||
external_id, 'active', created_at, created_at, created_at
|
||||
FROM han_app.entity_external_mapping
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM han_app.entity_external_mapping legacy
|
||||
LEFT JOIN bitrix_sync.entity_external_mapping canonical
|
||||
ON canonical.id = legacy.id
|
||||
AND canonical.entity_type = legacy.entity_type
|
||||
AND canonical.entity_id = legacy.entity_id
|
||||
AND canonical.external_id = legacy.external_id
|
||||
WHERE canonical.id IS NULL
|
||||
) THEN
|
||||
RAISE EXCEPTION 'canonical mapping verification failed';
|
||||
END IF;
|
||||
END IF;
|
||||
END $$;
|
||||
"""
|
||||
)
|
||||
|
||||
op.execute(
|
||||
"""
|
||||
CREATE OR REPLACE FUNCTION han_app.enqueue_contact_sync()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
SECURITY INVOKER
|
||||
SET search_path = han_app, pg_temp
|
||||
AS $$
|
||||
DECLARE
|
||||
v_user_id uuid;
|
||||
v_task_type varchar(64);
|
||||
v_reason varchar(64);
|
||||
v_dedup varchar(255);
|
||||
v_source_updated_at timestamptz;
|
||||
BEGIN
|
||||
IF current_setting('han.sync_suppress', true) = 'true' THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
IF TG_TABLE_NAME = 'user_identities' THEN
|
||||
v_user_id := NEW.id;
|
||||
v_source_updated_at := NEW.updated_at;
|
||||
IF TG_OP = 'INSERT' THEN
|
||||
IF NEW.record_status <> 'A' THEN RETURN NEW; END IF;
|
||||
v_task_type := 'contact.map_or_create';
|
||||
v_reason := 'identity_created';
|
||||
ELSIF OLD.record_status = 'A' AND NEW.record_status <> 'A' THEN
|
||||
v_task_type := 'contact.deactivate';
|
||||
v_reason := 'identity_deactivated';
|
||||
ELSIF OLD.record_status <> 'A' AND NEW.record_status = 'A' THEN
|
||||
v_task_type := 'contact.map_or_create';
|
||||
v_reason := 'identity_reactivated';
|
||||
ELSIF NEW.record_status = 'A'
|
||||
AND NEW.phone_number IS DISTINCT FROM OLD.phone_number THEN
|
||||
v_task_type := 'contact.update';
|
||||
v_reason := 'identity_phone_changed';
|
||||
ELSE
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
ELSE
|
||||
v_user_id := NEW.user_id;
|
||||
v_source_updated_at := NEW.updated_at;
|
||||
IF TG_OP = 'INSERT' THEN
|
||||
IF NEW.record_status <> 'A' THEN RETURN NEW; END IF;
|
||||
v_task_type := 'contact.map_or_create';
|
||||
v_reason := 'profile_created';
|
||||
ELSIF OLD.record_status = 'A' AND NEW.record_status <> 'A' THEN
|
||||
v_task_type := 'contact.deactivate';
|
||||
v_reason := 'profile_deactivated';
|
||||
ELSIF OLD.record_status <> 'A' AND NEW.record_status = 'A' THEN
|
||||
v_task_type := 'contact.map_or_create';
|
||||
v_reason := 'profile_reactivated';
|
||||
ELSE
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
v_dedup := v_task_type || ':' || v_user_id::text;
|
||||
INSERT INTO han_app.sync_queue (
|
||||
id, task_type, entity_type, entity_id, dedup_key, payload_json,
|
||||
status, attempt_count, next_attempt_at, created_at, updated_at
|
||||
) VALUES (
|
||||
gen_random_uuid(), v_task_type, 'contact', v_user_id, v_dedup,
|
||||
jsonb_build_object(
|
||||
'schema_version', 1,
|
||||
'user_id', v_user_id,
|
||||
'reason', v_reason,
|
||||
'source_updated_at', v_source_updated_at
|
||||
),
|
||||
'pending', 0, now(), now(), now()
|
||||
)
|
||||
ON CONFLICT (dedup_key)
|
||||
WHERE status IN ('pending','leased','retry_wait')
|
||||
DO UPDATE SET
|
||||
payload_json = EXCLUDED.payload_json,
|
||||
updated_at = now();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_profile_contact_sync ON han_app.client_profiles;
|
||||
CREATE TRIGGER trg_profile_contact_sync
|
||||
AFTER INSERT OR UPDATE OF record_status
|
||||
ON han_app.client_profiles
|
||||
FOR EACH ROW EXECUTE FUNCTION han_app.enqueue_contact_sync();
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
raise RuntimeError("Module-07 staged contract migration is forward-only")
|
||||
@@ -0,0 +1,74 @@
|
||||
"""Persist Message Safety v2 evidence and recovery locations.
|
||||
|
||||
Revision ID: 0012_safety_v2_checkpoint
|
||||
Revises: 0011_module07_contract
|
||||
Create Date: 2026-08-06
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0012_safety_v2_checkpoint"
|
||||
down_revision: str | None = "0011_module07_contract"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute(
|
||||
"""
|
||||
ALTER TABLE han_app.messages
|
||||
ADD COLUMN IF NOT EXISTS safety_processing_mode varchar(16),
|
||||
ADD COLUMN IF NOT EXISTS safety_config_version bigint,
|
||||
ADD COLUMN IF NOT EXISTS safety_rules_version varchar(128);
|
||||
|
||||
ALTER TABLE han_app.message_attachments
|
||||
ADD COLUMN IF NOT EXISTS quarantine_version_id varchar(1024),
|
||||
ADD COLUMN IF NOT EXISTS quarantine_etag varchar(1024);
|
||||
ALTER TABLE han_app.message_attachments
|
||||
DROP CONSTRAINT IF EXISTS message_attachments_scan_status_check;
|
||||
ALTER TABLE han_app.message_attachments
|
||||
ADD CONSTRAINT message_attachments_scan_status_check
|
||||
CHECK (scan_status IN ('pending','clean','bypassed','infected','failed')) NOT VALID;
|
||||
ALTER TABLE han_app.message_attachments
|
||||
VALIDATE CONSTRAINT message_attachments_scan_status_check;
|
||||
|
||||
ALTER TABLE han_app.safety_tasks
|
||||
ADD COLUMN IF NOT EXISTS poll_location varchar(1024),
|
||||
ADD COLUMN IF NOT EXISTS processing_mode varchar(16),
|
||||
ADD COLUMN IF NOT EXISTS config_version bigint,
|
||||
ADD COLUMN IF NOT EXISTS rules_version varchar(128),
|
||||
ADD COLUMN IF NOT EXISTS expires_at timestamptz;
|
||||
UPDATE han_app.safety_tasks
|
||||
SET poll_location = '/internal/safety/v2/messages/tasks/' || task_id,
|
||||
expires_at = deadline_at
|
||||
WHERE poll_location IS NULL OR expires_at IS NULL;
|
||||
ALTER TABLE han_app.safety_tasks
|
||||
ALTER COLUMN poll_location SET NOT NULL,
|
||||
ALTER COLUMN expires_at SET NOT NULL;
|
||||
"""
|
||||
)
|
||||
|
||||
# Notification uploads use the same safety contract.
|
||||
op.execute(
|
||||
"""
|
||||
ALTER TABLE han_app.client_upload_drafts
|
||||
ADD COLUMN IF NOT EXISTS quarantine_version_id varchar(1024),
|
||||
ADD COLUMN IF NOT EXISTS quarantine_etag varchar(1024),
|
||||
ADD COLUMN IF NOT EXISTS safety_processing_mode varchar(16),
|
||||
ADD COLUMN IF NOT EXISTS safety_config_version bigint,
|
||||
ADD COLUMN IF NOT EXISTS safety_rules_version varchar(128);
|
||||
ALTER TABLE han_app.client_upload_drafts
|
||||
DROP CONSTRAINT IF EXISTS client_upload_drafts_scan_status_check;
|
||||
ALTER TABLE han_app.client_upload_drafts
|
||||
ADD CONSTRAINT client_upload_drafts_scan_status_check
|
||||
CHECK (scan_status IN ('pending','clean','bypassed','infected','failed')) NOT VALID;
|
||||
ALTER TABLE han_app.client_upload_drafts
|
||||
VALIDATE CONSTRAINT client_upload_drafts_scan_status_check;
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
raise RuntimeError("Safety v2 checkpoint migration is forward-only")
|
||||
@@ -34,6 +34,10 @@ class Base(DeclarativeBase):
|
||||
type_annotation_map = {dict[str, Any]: JSON}
|
||||
|
||||
|
||||
class BitrixBase(DeclarativeBase):
|
||||
"""Models owned by bitrix-sync, excluded from han_app create_all."""
|
||||
|
||||
|
||||
class Common:
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
record_status: Mapped[str] = mapped_column(String(1), default="A", server_default="A")
|
||||
@@ -142,6 +146,9 @@ class Message(Common, Base):
|
||||
content_kind: Mapped[str] = mapped_column(String(16))
|
||||
text: Mapped[str] = mapped_column(Text, default="")
|
||||
safety_status: Mapped[str] = mapped_column(String(16))
|
||||
safety_processing_mode: Mapped[str | None] = mapped_column(String(16))
|
||||
safety_config_version: Mapped[int | None] = mapped_column(BigInteger)
|
||||
safety_rules_version: Mapped[str | None] = mapped_column(String(128))
|
||||
delivery_status: Mapped[str] = mapped_column(String(16))
|
||||
external_message_id: Mapped[str | None] = mapped_column(String(255))
|
||||
client_idempotency_key: Mapped[str | None] = mapped_column(String(128))
|
||||
@@ -152,7 +159,7 @@ class MessageAttachment(Common, Base):
|
||||
__tablename__ = "message_attachments"
|
||||
__table_args__ = (
|
||||
CheckConstraint("direction IN ('client_upload','company_inbound')"),
|
||||
CheckConstraint("scan_status IN ('pending','clean','infected','failed')"),
|
||||
CheckConstraint("scan_status IN ('pending','clean','bypassed','infected','failed')"),
|
||||
CheckConstraint("size_bytes > 0"),
|
||||
{"schema": SCHEMA},
|
||||
)
|
||||
@@ -171,6 +178,8 @@ class MessageAttachment(Common, Base):
|
||||
storage_bucket: Mapped[str] = mapped_column(String(255))
|
||||
object_key: Mapped[str] = mapped_column(String(1024))
|
||||
quarantine_object_key: Mapped[str | None] = mapped_column(String(1024))
|
||||
quarantine_version_id: Mapped[str | None] = mapped_column(String(1024))
|
||||
quarantine_etag: Mapped[str | None] = mapped_column(String(1024))
|
||||
upload_expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
|
||||
@@ -193,10 +202,15 @@ class SafetyTask(Base):
|
||||
__table_args__ = ({"schema": SCHEMA},)
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
task_id: Mapped[str] = mapped_column(String(255), unique=True)
|
||||
poll_location: Mapped[str] = mapped_column(String(1024))
|
||||
message_id: Mapped[uuid.UUID] = mapped_column(ForeignKey(f"{SCHEMA}.messages.id"), unique=True)
|
||||
attachment_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True))
|
||||
quarantine_object_key: Mapped[str | None] = mapped_column(String(1024))
|
||||
status: Mapped[str] = mapped_column(String(16))
|
||||
processing_mode: Mapped[str | None] = mapped_column(String(16))
|
||||
config_version: Mapped[int | None] = mapped_column(BigInteger)
|
||||
rules_version: Mapped[str | None] = mapped_column(String(128))
|
||||
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
|
||||
deadline_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
|
||||
next_poll_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
|
||||
attempt_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||
@@ -321,18 +335,32 @@ class PopularQuestion(Common, Base):
|
||||
|
||||
class SyncQueue(Base):
|
||||
__tablename__ = "sync_queue"
|
||||
__table_args__ = ({"schema": SCHEMA},)
|
||||
__table_args__ = (
|
||||
CheckConstraint(
|
||||
"status IN ('pending','leased','processed','retry_wait','dead_letter','cancelled')"
|
||||
),
|
||||
Index("ix_sync_queue_claim", "status", "next_attempt_at", "created_at"),
|
||||
Index("ix_sync_queue_entity_history", "entity_type", "entity_id", "created_at"),
|
||||
{"schema": SCHEMA},
|
||||
)
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
task_type: Mapped[str] = mapped_column(String(64))
|
||||
entity_type: Mapped[str] = mapped_column(String(64))
|
||||
entity_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True))
|
||||
dedup_key: Mapped[str] = mapped_column(String(255), unique=True)
|
||||
dedup_key: Mapped[str] = mapped_column(String(255))
|
||||
payload_json: Mapped[dict[str, Any]] = mapped_column(JSON)
|
||||
status: Mapped[str] = mapped_column(String(16), default="pending")
|
||||
attempt_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||
next_attempt_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now()
|
||||
)
|
||||
locked_by: Mapped[str | None] = mapped_column(String(128))
|
||||
locked_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
lease_token: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True))
|
||||
last_error_code: Mapped[str | None] = mapped_column(String(64))
|
||||
last_error_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
cancel_reason: Mapped[str | None] = mapped_column(String(255))
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
@@ -347,6 +375,53 @@ class EntityExternalMapping(Base):
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
|
||||
class BitrixEntityExternalMapping(BitrixBase):
|
||||
__tablename__ = "entity_external_mapping"
|
||||
__table_args__ = ({"schema": "bitrix_sync"},)
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
entity_type: Mapped[str] = mapped_column(String(64))
|
||||
entity_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True))
|
||||
external_system: Mapped[str] = mapped_column(String(32), default="bitrix24")
|
||||
external_entity_type: Mapped[str] = mapped_column(String(32), default="contact")
|
||||
external_id: Mapped[str] = mapped_column(String(128))
|
||||
status: Mapped[str] = mapped_column(String(16), default="active")
|
||||
opened_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
closed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
close_reason: Mapped[str | None] = mapped_column(String(64))
|
||||
workflow_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True))
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
|
||||
Index(
|
||||
"uq_sync_queue_active_dedup",
|
||||
SyncQueue.dedup_key,
|
||||
unique=True,
|
||||
postgresql_where=SyncQueue.status.in_(["pending", "leased", "retry_wait"]),
|
||||
)
|
||||
Index(
|
||||
"ix_sync_queue_expired_lease",
|
||||
SyncQueue.locked_until,
|
||||
postgresql_where=SyncQueue.status == "leased",
|
||||
)
|
||||
Index(
|
||||
"uq_external_mapping_active_entity",
|
||||
BitrixEntityExternalMapping.external_system,
|
||||
BitrixEntityExternalMapping.entity_type,
|
||||
BitrixEntityExternalMapping.entity_id,
|
||||
unique=True,
|
||||
postgresql_where=BitrixEntityExternalMapping.status == "active",
|
||||
)
|
||||
Index(
|
||||
"uq_external_mapping_active_external",
|
||||
BitrixEntityExternalMapping.external_system,
|
||||
BitrixEntityExternalMapping.external_entity_type,
|
||||
BitrixEntityExternalMapping.external_id,
|
||||
unique=True,
|
||||
postgresql_where=BitrixEntityExternalMapping.status == "active",
|
||||
)
|
||||
|
||||
|
||||
class Database:
|
||||
def __init__(self, url: str) -> None:
|
||||
self.engine: AsyncEngine = create_postgres_engine(url, pool_pre_ping=True)
|
||||
|
||||
@@ -6,6 +6,7 @@ import socket
|
||||
import time
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
@@ -25,9 +26,19 @@ return {current, ttl}
|
||||
|
||||
|
||||
class DependencyFailure(Exception):
|
||||
def __init__(self, code: str = "dependency_unavailable", timeout: bool = False) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
code: str = "dependency_unavailable",
|
||||
timeout: bool = False,
|
||||
*,
|
||||
terminal: bool = False,
|
||||
retryable: bool = True,
|
||||
) -> None:
|
||||
super().__init__(code)
|
||||
self.code = code
|
||||
self.timeout = timeout
|
||||
self.terminal = terminal
|
||||
self.retryable = retryable
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
@@ -106,20 +117,37 @@ class SafetyClient:
|
||||
async def check(self, payload: dict[str, Any], request_id: str) -> dict[str, Any]:
|
||||
return await self._call(
|
||||
"POST",
|
||||
"/internal/safety/v1/messages/check",
|
||||
f"{self.settings.message_safety_api_prefix}/messages/check",
|
||||
request_id,
|
||||
json=payload,
|
||||
timeout=self.settings.message_safety_post_timeout_sec,
|
||||
)
|
||||
|
||||
async def poll(self, task_id: str, request_id: str) -> dict[str, Any]:
|
||||
async def poll(self, location: str, request_id: str) -> dict[str, Any]:
|
||||
path = self._poll_path(location)
|
||||
return await self._call(
|
||||
"GET",
|
||||
f"/internal/safety/v1/messages/tasks/{task_id}",
|
||||
path,
|
||||
request_id,
|
||||
timeout=2,
|
||||
)
|
||||
|
||||
def _poll_path(self, location: str) -> str:
|
||||
expected_prefix = f"{self.settings.message_safety_api_prefix}/messages/tasks/"
|
||||
parsed = urlparse(location)
|
||||
if parsed.scheme or parsed.netloc or parsed.query or parsed.fragment:
|
||||
raise DependencyFailure("invalid_safety_location", terminal=True, retryable=False)
|
||||
if not parsed.path.startswith(expected_prefix):
|
||||
raise DependencyFailure("invalid_safety_location", terminal=True, retryable=False)
|
||||
task_id = parsed.path.removeprefix(expected_prefix)
|
||||
try:
|
||||
uuid.UUID(task_id)
|
||||
except ValueError as exc:
|
||||
raise DependencyFailure(
|
||||
"invalid_safety_location", terminal=True, retryable=False
|
||||
) from exc
|
||||
return parsed.path
|
||||
|
||||
async def _call(self, method: str, path: str, request_id: str, **kwargs: Any) -> dict[str, Any]:
|
||||
if not self.breaker.allow():
|
||||
raise DependencyFailure()
|
||||
@@ -140,9 +168,31 @@ class SafetyClient:
|
||||
except httpx.HTTPError as exc:
|
||||
self.breaker.failure()
|
||||
raise DependencyFailure() from exc
|
||||
if response.status_code == 401 or response.status_code >= 500:
|
||||
if response.status_code >= 500:
|
||||
self.breaker.failure()
|
||||
raise DependencyFailure()
|
||||
code, terminal, retryable = "dependency_unavailable", False, True
|
||||
try:
|
||||
details = response.json().get("error", {}).get("details", {})
|
||||
code = response.json().get("error", {}).get("code", code)
|
||||
terminal = details.get("terminal") is True
|
||||
retryable = details.get("retryable") is not False
|
||||
except (AttributeError, ValueError):
|
||||
pass
|
||||
raise DependencyFailure(code, terminal=terminal, retryable=retryable)
|
||||
if response.status_code not in (200, 202, 403):
|
||||
if response.status_code == 401:
|
||||
self.breaker.failure()
|
||||
code = "safety_request_rejected"
|
||||
try:
|
||||
code = response.json().get("error", {}).get("code", code)
|
||||
except (AttributeError, ValueError):
|
||||
pass
|
||||
retryable = response.status_code in (404, 429)
|
||||
raise DependencyFailure(
|
||||
code,
|
||||
terminal=not retryable,
|
||||
retryable=retryable,
|
||||
)
|
||||
try:
|
||||
body = response.json()
|
||||
except ValueError as exc:
|
||||
@@ -151,10 +201,66 @@ class SafetyClient:
|
||||
if not isinstance(body, dict):
|
||||
self.breaker.failure()
|
||||
raise DependencyFailure()
|
||||
status = response.status_code
|
||||
expected_verdict = {200: "allow", 202: "pending", 403: "deny"}[status]
|
||||
if (
|
||||
body.get("verdict") != expected_verdict
|
||||
or body.get("processing_mode") not in ("standard", "mock")
|
||||
or type(body.get("config_version")) is not int
|
||||
or not body.get("rules_version")
|
||||
):
|
||||
self.breaker.failure()
|
||||
raise DependencyFailure("invalid_safety_response")
|
||||
if status == 202:
|
||||
location = response.headers.get("Location")
|
||||
retry_after = response.headers.get("Retry-After")
|
||||
if (
|
||||
body["processing_mode"] != "standard"
|
||||
or not location
|
||||
or not retry_after
|
||||
or not body.get("task_id")
|
||||
or not body.get("expires_at")
|
||||
or type(body.get("poll_after_ms")) is not int
|
||||
):
|
||||
self.breaker.failure()
|
||||
raise DependencyFailure("invalid_safety_response")
|
||||
try:
|
||||
location_task_id = self._poll_path(location).rsplit("/", 1)[-1]
|
||||
except DependencyFailure as exc:
|
||||
self.breaker.failure()
|
||||
raise DependencyFailure("invalid_safety_response") from exc
|
||||
if body["task_id"] != location_task_id:
|
||||
self.breaker.failure()
|
||||
raise DependencyFailure("invalid_safety_response")
|
||||
try:
|
||||
if int(retry_after) <= 0 or body["poll_after_ms"] <= 0:
|
||||
raise ValueError
|
||||
datetime_value = body["expires_at"].replace("Z", "+00:00")
|
||||
datetime.fromisoformat(datetime_value)
|
||||
except (AttributeError, TypeError, ValueError) as exc:
|
||||
self.breaker.failure()
|
||||
raise DependencyFailure("invalid_safety_response") from exc
|
||||
body["_location"] = location
|
||||
body["_retry_after"] = retry_after
|
||||
elif not body.get("rule_id") or (
|
||||
status == 403 and body.get("reason_code") != "message_blocked"
|
||||
):
|
||||
self.breaker.failure()
|
||||
raise DependencyFailure("invalid_safety_response")
|
||||
self.breaker.success()
|
||||
body["_status"] = response.status_code
|
||||
return body
|
||||
|
||||
async def ready(self) -> bool:
|
||||
try:
|
||||
response = await self.http.get(
|
||||
f"{str(self.settings.message_safety_url).rstrip('/')}/health/ready",
|
||||
timeout=2,
|
||||
)
|
||||
return response.status_code == 200
|
||||
except httpx.HTTPError:
|
||||
return False
|
||||
|
||||
|
||||
class OpenLinesClient:
|
||||
def __init__(self, settings: Settings, http: httpx.AsyncClient) -> None:
|
||||
@@ -272,7 +378,14 @@ class S3Client:
|
||||
async def head(self, bucket: str, key: str) -> dict[str, Any]:
|
||||
return await asyncio.to_thread(self.client.head_object, Bucket=bucket, Key=key)
|
||||
|
||||
async def promote(self, source_key: str, destination_key: str) -> None:
|
||||
async def promote(
|
||||
self,
|
||||
source_key: str,
|
||||
destination_key: str,
|
||||
*,
|
||||
version_id: str,
|
||||
etag: str,
|
||||
) -> None:
|
||||
await asyncio.to_thread(
|
||||
self.client.copy_object,
|
||||
Bucket=self.settings.selectel_s3_bucket_attachments,
|
||||
@@ -280,13 +393,13 @@ class S3Client:
|
||||
CopySource={
|
||||
"Bucket": self.settings.selectel_s3_bucket_quarantine,
|
||||
"Key": source_key,
|
||||
"VersionId": version_id,
|
||||
},
|
||||
CopySourceIfMatch=etag,
|
||||
)
|
||||
await asyncio.to_thread(
|
||||
self.client.delete_object,
|
||||
Bucket=self.settings.selectel_s3_bucket_quarantine,
|
||||
Key=source_key,
|
||||
)
|
||||
# Keep the immutable source version until quarantine lifecycle expiry.
|
||||
# A crash after copy but before the DB checkpoint can then safely retry
|
||||
# the same conditional copy without losing its source.
|
||||
|
||||
async def delete_quarantine(self, key: str) -> None:
|
||||
await asyncio.to_thread(
|
||||
|
||||
@@ -138,12 +138,15 @@ async def lifespan(app: FastAPI):
|
||||
app.state.settings = settings
|
||||
app.state.db = Database(settings.database_url)
|
||||
app.state.http = httpx.AsyncClient()
|
||||
app.state.safety_http = httpx.AsyncClient(
|
||||
verify=settings.message_safety_ca_file or True
|
||||
)
|
||||
app.state.redis = redis.from_url(settings.redis_url, decode_responses=True)
|
||||
app.state.redis_rt = redis.from_url(settings.redis_realtime_url, decode_responses=True)
|
||||
app.state.jwks = JWKSValidator(settings, app.state.http)
|
||||
app.state.rate_limiter = RateLimiter(app.state.redis)
|
||||
app.state.idempotency = RedisIdempotency(app.state.redis)
|
||||
app.state.safety = SafetyClient(settings, app.state.http)
|
||||
app.state.safety = SafetyClient(settings, app.state.safety_http)
|
||||
app.state.openlines = OpenLinesClient(settings, app.state.http)
|
||||
app.state.s3 = S3Client(settings)
|
||||
app.state.realtime = RealtimeFanout(app.state.redis_rt)
|
||||
@@ -168,6 +171,7 @@ async def lifespan(app: FastAPI):
|
||||
with suppress(asyncio.CancelledError):
|
||||
await task
|
||||
await app.state.http.aclose()
|
||||
await app.state.safety_http.aclose()
|
||||
await app.state.redis.aclose()
|
||||
await app.state.redis_rt.aclose()
|
||||
await app.state.db.close()
|
||||
@@ -175,7 +179,7 @@ async def lifespan(app: FastAPI):
|
||||
telemetry.shutdown()
|
||||
|
||||
|
||||
EXPECTED_API_DB_REVISION = "0010_contact_map_dedup"
|
||||
EXPECTED_API_DB_REVISION = "0012_safety_v2_checkpoint"
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
@@ -486,14 +490,7 @@ async def ready(request: Request, db: Session):
|
||||
except Exception:
|
||||
components[name] = "failed"
|
||||
components["jwks"] = "ok" if request.app.state.jwks.has_keys else "failed"
|
||||
try:
|
||||
safety_response = await request.app.state.http.get(
|
||||
f"{str(request.app.state.settings.message_safety_url).rstrip('/')}/health/ready",
|
||||
timeout=2,
|
||||
)
|
||||
components["safety"] = "ok" if safety_response.is_success else "failed"
|
||||
except httpx.HTTPError:
|
||||
components["safety"] = "failed"
|
||||
components["safety"] = "ok" if await request.app.state.safety.ready() else "failed"
|
||||
components["openlines"] = "ok" if await request.app.state.openlines.ready() else "degraded"
|
||||
components["s3"] = "ok" if await request.app.state.s3.ready() else "degraded"
|
||||
critical = {"postgres", "settings", "redis", "jwks", "safety"}
|
||||
|
||||
@@ -240,7 +240,7 @@ class ClientUploadDraft(Base):
|
||||
__table_args__ = (
|
||||
CheckConstraint("context_type IN ('notification')"),
|
||||
CheckConstraint("size_bytes > 0"),
|
||||
CheckConstraint("scan_status IN ('pending','clean','infected','failed')"),
|
||||
CheckConstraint("scan_status IN ('pending','clean','bypassed','infected','failed')"),
|
||||
CheckConstraint("state IN ('draft','submitted','discarded')"),
|
||||
Index("ix_client_upload_drafts_context", "user_id", "context_type", "context_id"),
|
||||
Index("ix_client_upload_drafts_scan", "scan_status", "updated_at"),
|
||||
@@ -262,6 +262,11 @@ class ClientUploadDraft(Base):
|
||||
storage_bucket: Mapped[str] = mapped_column(String(255))
|
||||
object_key: Mapped[str] = mapped_column(String(1024))
|
||||
quarantine_object_key: Mapped[str | None] = mapped_column(String(1024))
|
||||
quarantine_version_id: Mapped[str | None] = mapped_column(String(1024))
|
||||
quarantine_etag: Mapped[str | None] = mapped_column(String(1024))
|
||||
safety_processing_mode: Mapped[str | None] = mapped_column(String(16))
|
||||
safety_config_version: Mapped[int | None] = mapped_column(BigInteger)
|
||||
safety_rules_version: Mapped[str | None] = mapped_column(String(128))
|
||||
upload_expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
state: Mapped[str] = mapped_column(String(16), default="draft")
|
||||
|
||||
@@ -1004,7 +1004,14 @@ async def complete_upload(
|
||||
or metadata.get("ContentType") != draft.mime_type
|
||||
):
|
||||
raise DomainError("attachment_invalid", 400, "Uploaded metadata differs")
|
||||
version_id, etag = metadata.get("VersionId"), metadata.get("ETag")
|
||||
if not version_id or not etag:
|
||||
raise DomainError(
|
||||
"dependency_unavailable", 503, "Versioned object metadata is unavailable"
|
||||
)
|
||||
draft.checksum_sha256 = checksum
|
||||
draft.quarantine_version_id = str(version_id)
|
||||
draft.quarantine_etag = str(etag)
|
||||
verdict = await safety.check(
|
||||
{
|
||||
"message_id": str(draft.id),
|
||||
@@ -1013,6 +1020,8 @@ async def complete_upload(
|
||||
"attachment": {
|
||||
"attachment_id": str(draft.id),
|
||||
"quarantine_object_key": draft.quarantine_object_key,
|
||||
"quarantine_version_id": draft.quarantine_version_id,
|
||||
"quarantine_etag": draft.quarantine_etag,
|
||||
"checksum": body.checksum,
|
||||
"mime_type": draft.mime_type,
|
||||
"size_bytes": draft.size_bytes,
|
||||
@@ -1020,25 +1029,33 @@ async def complete_upload(
|
||||
},
|
||||
request_id,
|
||||
)
|
||||
if verdict["_status"] == 203:
|
||||
if verdict["_status"] == 202:
|
||||
deadline = datetime.now(UTC) + timedelta(
|
||||
seconds=safety.settings.message_safety_task_poll_max_sec
|
||||
)
|
||||
while verdict["_status"] == 203 and datetime.now(UTC) < deadline:
|
||||
while verdict["_status"] == 202 and datetime.now(UTC) < deadline:
|
||||
await asyncio.sleep(safety.settings.message_safety_task_poll_interval_sec)
|
||||
verdict = await safety.poll(verdict["task_id"], request_id)
|
||||
verdict = await safety.poll(verdict["_location"], request_id)
|
||||
draft.safety_processing_mode = verdict.get("processing_mode")
|
||||
draft.safety_config_version = verdict.get("config_version")
|
||||
draft.safety_rules_version = verdict.get("rules_version")
|
||||
if verdict["_status"] == 200:
|
||||
destination = (
|
||||
f"attachments/users/{user_id}/{draft.context_type}/{draft.context_id}/{draft.id}"
|
||||
)
|
||||
await s3.promote(draft.quarantine_object_key or draft.object_key, destination)
|
||||
await s3.promote(
|
||||
draft.quarantine_object_key or draft.object_key,
|
||||
destination,
|
||||
version_id=draft.quarantine_version_id or "",
|
||||
etag=draft.quarantine_etag or "",
|
||||
)
|
||||
draft.storage_bucket = s3.settings.selectel_s3_bucket_attachments
|
||||
draft.object_key = destination
|
||||
draft.quarantine_object_key = None
|
||||
draft.scan_status = "clean"
|
||||
elif verdict["_status"] == 403 or (
|
||||
verdict["_status"] == 400 and verdict.get("verdict") == "deny"
|
||||
):
|
||||
draft.scan_status = (
|
||||
"bypassed" if verdict["processing_mode"] == "mock" else "clean"
|
||||
)
|
||||
elif verdict["_status"] == 403:
|
||||
if draft.quarantine_object_key:
|
||||
await s3.delete_quarantine(draft.quarantine_object_key)
|
||||
draft.scan_status = "infected"
|
||||
|
||||
@@ -737,7 +737,14 @@ async def complete_attachment(
|
||||
raise DomainError("dependency_unavailable", 503, "Object storage is unavailable") from exc
|
||||
if int(head["ContentLength"]) != item.size_bytes or head.get("ContentType") != item.mime_type:
|
||||
raise DomainError("attachment_checksum_mismatch", 400, "Uploaded metadata does not match")
|
||||
version_id, etag = head.get("VersionId"), head.get("ETag")
|
||||
if not version_id or not etag:
|
||||
raise DomainError(
|
||||
"dependency_unavailable", 503, "Versioned object metadata is unavailable"
|
||||
)
|
||||
item.checksum_sha256 = checksum
|
||||
item.quarantine_version_id = str(version_id)
|
||||
item.quarantine_etag = str(etag)
|
||||
item.completed_at = datetime.now(UTC)
|
||||
session.add(
|
||||
audit(
|
||||
@@ -886,6 +893,8 @@ async def send_message(
|
||||
payload["attachment"] = {
|
||||
"attachment_id": str(attachment.id),
|
||||
"quarantine_object_key": attachment.quarantine_object_key,
|
||||
"quarantine_version_id": attachment.quarantine_version_id,
|
||||
"quarantine_etag": attachment.quarantine_etag,
|
||||
"checksum": f"sha256:{attachment.checksum_sha256}",
|
||||
"mime_type": attachment.mime_type,
|
||||
"size_bytes": attachment.size_bytes,
|
||||
@@ -893,14 +902,20 @@ async def send_message(
|
||||
outbox: DeliveryOutbox | None = None
|
||||
try:
|
||||
verdict = await safety.check(payload, context.request_id)
|
||||
if verdict["_status"] == 203:
|
||||
task: SafetyTask | None = None
|
||||
if verdict["_status"] == 202:
|
||||
task_id = verdict["task_id"]
|
||||
task = SafetyTask(
|
||||
task_id=task_id,
|
||||
poll_location=verdict["_location"],
|
||||
message_id=message.id,
|
||||
attachment_id=attachment.id if attachment else None,
|
||||
quarantine_object_key=attachment.quarantine_object_key if attachment else None,
|
||||
status="polling",
|
||||
processing_mode=verdict["processing_mode"],
|
||||
config_version=verdict["config_version"],
|
||||
rules_version=verdict["rules_version"],
|
||||
expires_at=datetime.fromisoformat(verdict["expires_at"].replace("Z", "+00:00")),
|
||||
deadline_at=now
|
||||
+ timedelta(seconds=settings.message_safety_task_poll_max_sec + 900),
|
||||
next_poll_at=now,
|
||||
@@ -910,16 +925,18 @@ async def send_message(
|
||||
deadline = time_monotonic() + settings.message_safety_task_poll_max_sec
|
||||
while time_monotonic() < deadline:
|
||||
await sleep(settings.message_safety_task_poll_interval_sec)
|
||||
verdict = await safety.poll(task_id, context.request_id)
|
||||
if verdict["_status"] != 203:
|
||||
verdict = await safety.poll(task.poll_location, context.request_id)
|
||||
if verdict["_status"] != 202:
|
||||
break
|
||||
task.poll_location = verdict["_location"]
|
||||
else:
|
||||
raise DependencyFailure(timeout=True)
|
||||
if verdict["_status"] == 403 or (
|
||||
verdict["_status"] == 400
|
||||
and verdict.get("error", {}).get("code") == "stub_final_error"
|
||||
and verdict.get("verdict") == "deny"
|
||||
):
|
||||
message.safety_processing_mode = verdict["processing_mode"]
|
||||
message.safety_config_version = verdict["config_version"]
|
||||
message.safety_rules_version = verdict["rules_version"]
|
||||
if task:
|
||||
task.status = "completed"
|
||||
if verdict["_status"] == 403:
|
||||
message.text = ""
|
||||
message.safety_status = "blocked"
|
||||
message.delivery_status = "rejected"
|
||||
@@ -957,11 +974,18 @@ async def send_message(
|
||||
raise DependencyFailure()
|
||||
if attachment and attachment.quarantine_object_key:
|
||||
destination = f"attachments/dialogs/{dialog_id}/{attachment.id}"
|
||||
await s3.promote(attachment.quarantine_object_key, destination)
|
||||
await s3.promote(
|
||||
attachment.quarantine_object_key,
|
||||
destination,
|
||||
version_id=attachment.quarantine_version_id or "",
|
||||
etag=attachment.quarantine_etag or "",
|
||||
)
|
||||
attachment.storage_bucket = settings.selectel_s3_bucket_attachments
|
||||
attachment.object_key = destination
|
||||
attachment.quarantine_object_key = None
|
||||
attachment.scan_status = "clean"
|
||||
attachment.scan_status = (
|
||||
"bypassed" if verdict["processing_mode"] == "mock" else "clean"
|
||||
)
|
||||
message.safety_status = "allowed"
|
||||
outbox = DeliveryOutbox(
|
||||
message_id=message.id,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from functools import lru_cache
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import AnyHttpUrl, Field, SecretStr
|
||||
from pydantic import AnyHttpUrl, Field, SecretStr, model_validator
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
@@ -25,6 +26,10 @@ class Settings(BaseSettings):
|
||||
|
||||
message_safety_url: AnyHttpUrl = Field(alias="MESSAGE_SAFETY_URL")
|
||||
message_safety_service_token: SecretStr = Field(alias="MESSAGE_SAFETY_SERVICE_TOKEN")
|
||||
message_safety_ca_file: str | None = Field(default=None, alias="MESSAGE_SAFETY_CA_FILE")
|
||||
message_safety_api_prefix: Literal["/internal/safety/v2"] = Field(
|
||||
default="/internal/safety/v2", alias="MESSAGE_SAFETY_API_PREFIX"
|
||||
)
|
||||
message_safety_post_timeout_sec: float = Field(
|
||||
default=5, alias="MESSAGE_SAFETY_POST_TIMEOUT_SEC"
|
||||
)
|
||||
@@ -71,6 +76,15 @@ class Settings(BaseSettings):
|
||||
default=None, alias="NOTIFICATIONS_TOKEN_PRODUCER_TEST"
|
||||
)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def require_safety_tls_in_deployed_environments(self) -> "Settings":
|
||||
if self.app_env not in {"local", "test"}:
|
||||
if str(self.message_safety_url).split(":", 1)[0] != "https":
|
||||
raise ValueError("MESSAGE_SAFETY_URL must use HTTPS")
|
||||
if not self.message_safety_ca_file:
|
||||
raise ValueError("MESSAGE_SAFETY_CA_FILE is required")
|
||||
return self
|
||||
|
||||
@property
|
||||
def issuer(self) -> str:
|
||||
return f"{str(self.keycloak_public_url).rstrip('/')}/realms/{self.keycloak_realm}"
|
||||
|
||||
@@ -7,7 +7,15 @@ import redis.asyncio as redis
|
||||
import structlog
|
||||
from sqlalchemy import delete, select
|
||||
|
||||
from app.db import Database, DeliveryOutbox, Dialog, Message, MessageAttachment, SafetyTask
|
||||
from app.db import (
|
||||
Database,
|
||||
DeliveryOutbox,
|
||||
Dialog,
|
||||
Message,
|
||||
MessageAttachment,
|
||||
SafetyTask,
|
||||
UserIdentity,
|
||||
)
|
||||
from app.integrations import (
|
||||
DependencyFailure,
|
||||
OpenLinesClient,
|
||||
@@ -107,7 +115,7 @@ async def safety_once(
|
||||
.where(
|
||||
SafetyTask.status.in_(["polling", "failed"]),
|
||||
SafetyTask.next_poll_at <= datetime.now(UTC),
|
||||
SafetyTask.deadline_at > datetime.now(UTC),
|
||||
SafetyTask.expires_at > datetime.now(UTC),
|
||||
)
|
||||
.with_for_update(skip_locked=True)
|
||||
.limit(batch_size)
|
||||
@@ -127,7 +135,7 @@ async def safety_once(
|
||||
continue
|
||||
message = None
|
||||
try:
|
||||
verdict = await safety.poll(task.task_id, f"worker-{worker_id}")
|
||||
verdict = await safety.poll(task.poll_location, f"worker-{worker_id}")
|
||||
message = await session.get(Message, task.message_id)
|
||||
attachment = (
|
||||
await session.get(MessageAttachment, task.attachment_id)
|
||||
@@ -135,16 +143,70 @@ async def safety_once(
|
||||
else None
|
||||
)
|
||||
if verdict["_status"] == 200 and message:
|
||||
message.safety_processing_mode = verdict["processing_mode"]
|
||||
message.safety_config_version = verdict["config_version"]
|
||||
message.safety_rules_version = verdict["rules_version"]
|
||||
if attachment and attachment.quarantine_object_key:
|
||||
destination = f"attachments/dialogs/{message.dialog_id}/{attachment.id}"
|
||||
await s3.promote(attachment.quarantine_object_key, destination)
|
||||
await s3.promote(
|
||||
attachment.quarantine_object_key,
|
||||
destination,
|
||||
version_id=attachment.quarantine_version_id or "",
|
||||
etag=attachment.quarantine_etag or "",
|
||||
)
|
||||
attachment.storage_bucket = s3.settings.selectel_s3_bucket_attachments
|
||||
attachment.object_key = destination
|
||||
attachment.quarantine_object_key = None
|
||||
attachment.scan_status = "clean"
|
||||
attachment.scan_status = (
|
||||
"bypassed"
|
||||
if verdict["processing_mode"] == "mock"
|
||||
else "clean"
|
||||
)
|
||||
message.safety_status = "allowed"
|
||||
task.status = "completed"
|
||||
dialog = await session.get(Dialog, message.dialog_id)
|
||||
user = (
|
||||
await session.get(UserIdentity, dialog.user_id)
|
||||
if dialog
|
||||
else None
|
||||
)
|
||||
if dialog and user:
|
||||
session.add(
|
||||
DeliveryOutbox(
|
||||
message_id=message.id,
|
||||
external_chat_id=dialog.id,
|
||||
payload_json={
|
||||
"message_id": str(message.id),
|
||||
"external_chat_id": str(dialog.id),
|
||||
"occurred_at": message.occurred_at.isoformat(),
|
||||
"user": {
|
||||
"id": str(user.id),
|
||||
"display_name": user.phone_number,
|
||||
},
|
||||
"message": {
|
||||
"content_kind": message.content_kind,
|
||||
"text": message.text,
|
||||
"files": (
|
||||
[{
|
||||
"attachment_id": str(attachment.id),
|
||||
"name": attachment.safe_file_name,
|
||||
"mime_type": attachment.mime_type,
|
||||
"size_bytes": attachment.size_bytes,
|
||||
"_storage_bucket": attachment.storage_bucket,
|
||||
"_object_key": attachment.object_key,
|
||||
}]
|
||||
if attachment
|
||||
else []
|
||||
),
|
||||
},
|
||||
},
|
||||
next_attempt_at=datetime.now(UTC),
|
||||
)
|
||||
)
|
||||
elif verdict["_status"] == 403 and message:
|
||||
message.safety_processing_mode = verdict["processing_mode"]
|
||||
message.safety_config_version = verdict["config_version"]
|
||||
message.safety_rules_version = verdict["rules_version"]
|
||||
message.text = ""
|
||||
message.safety_status = "blocked"
|
||||
message.delivery_status = "rejected"
|
||||
@@ -153,10 +215,18 @@ async def safety_once(
|
||||
attachment.scan_status = "infected"
|
||||
task.status = "completed"
|
||||
else:
|
||||
if verdict["_status"] == 202:
|
||||
task.poll_location = verdict["_location"]
|
||||
task.next_poll_at = datetime.now(UTC) + timedelta(seconds=2)
|
||||
except DependencyFailure:
|
||||
except DependencyFailure as exc:
|
||||
task.attempt_count += 1
|
||||
task.status = "failed"
|
||||
terminal = exc.terminal or (
|
||||
exc.code == "task_not_found" and task.attempt_count >= 2
|
||||
)
|
||||
task.status = "terminal_failed" if terminal else "failed"
|
||||
task.last_error_code = exc.code
|
||||
if terminal and message:
|
||||
message.delivery_status = "failed"
|
||||
task.next_poll_at = datetime.now(UTC) + timedelta(
|
||||
seconds=min(300, 2**task.attempt_count)
|
||||
)
|
||||
|
||||
@@ -28,6 +28,8 @@ services:
|
||||
KEYCLOAK_REALM: ${KEYCLOAK_REALM}
|
||||
KEYCLOAK_AUDIENCE: ${KEYCLOAK_AUDIENCE}
|
||||
MESSAGE_SAFETY_URL: ${MESSAGE_SAFETY_URL}
|
||||
MESSAGE_SAFETY_API_PREFIX: /internal/safety/v2
|
||||
MESSAGE_SAFETY_CA_FILE: /run/config/message-safety-internal-ca.pem
|
||||
MESSAGE_SAFETY_POST_TIMEOUT_SEC: ${MESSAGE_SAFETY_POST_TIMEOUT_SEC:-5}
|
||||
MESSAGE_SAFETY_TASK_POLL_INTERVAL_SEC: ${MESSAGE_SAFETY_TASK_POLL_INTERVAL_SEC:-2}
|
||||
MESSAGE_SAFETY_TASK_POLL_MAX_SEC: ${MESSAGE_SAFETY_TASK_POLL_MAX_SEC:-300}
|
||||
@@ -53,6 +55,11 @@ services:
|
||||
- selectel_s3_access_key
|
||||
- selectel_s3_secret_key
|
||||
- cursor_hmac_secret
|
||||
volumes:
|
||||
- type: bind
|
||||
source: ${MESSAGE_SAFETY_CA_HOST_PATH}
|
||||
target: /run/config/message-safety-internal-ca.pem
|
||||
read_only: true
|
||||
healthcheck:
|
||||
test:
|
||||
- CMD
|
||||
|
||||
@@ -63,17 +63,35 @@ async def test_s3_presigned_urls_use_virtual_hosted_addressing() -> None:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_safety_contract_status_and_service_token() -> None:
|
||||
task_id = str(uuid.uuid4())
|
||||
|
||||
async def handler(request: httpx.Request) -> httpx.Response:
|
||||
assert request.headers["X-Service-Token"] == "safety-token"
|
||||
assert request.url.path == "/internal/safety/v1/messages/check"
|
||||
return httpx.Response(203, json={"verdict": "pending", "task_id": "task-1"})
|
||||
assert request.url.path == "/internal/safety/v2/messages/check"
|
||||
return httpx.Response(
|
||||
202,
|
||||
headers={
|
||||
"Location": f"/internal/safety/v2/messages/tasks/{task_id}",
|
||||
"Retry-After": "2",
|
||||
},
|
||||
json={
|
||||
"verdict": "pending",
|
||||
"processing_mode": "standard",
|
||||
"config_version": 1,
|
||||
"rules_version": "2026-01-01",
|
||||
"task_id": task_id,
|
||||
"poll_after_ms": 2000,
|
||||
"expires_at": "2026-08-06T12:00:00Z",
|
||||
},
|
||||
)
|
||||
|
||||
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http:
|
||||
result = await SafetyClient(settings(), http).check(
|
||||
{"message_id": str(uuid.uuid4()), "content_kind": "text", "text": "hello"},
|
||||
"request-1",
|
||||
)
|
||||
assert result == {"verdict": "pending", "task_id": "task-1", "_status": 203}
|
||||
assert result["_status"] == 202
|
||||
assert result["_location"] == f"/internal/safety/v2/messages/tasks/{task_id}"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -85,12 +103,23 @@ async def test_safety_file_body_uses_exact_attachment_schema() -> None:
|
||||
assert body["attachment"] == {
|
||||
"attachment_id": str(attachment_id),
|
||||
"quarantine_object_key": "quarantine/users/u/file",
|
||||
"quarantine_version_id": "version-1",
|
||||
"quarantine_etag": '"etag-1"',
|
||||
"mime_type": "application/pdf",
|
||||
"size_bytes": 42,
|
||||
"checksum": "sha256:" + "a" * 64,
|
||||
}
|
||||
assert "file" not in body
|
||||
return httpx.Response(200, json={"verdict": "allow"})
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"verdict": "allow",
|
||||
"processing_mode": "standard",
|
||||
"config_version": 1,
|
||||
"rules_version": "2026-01-01",
|
||||
"rule_id": "safety.all_checks_passed",
|
||||
},
|
||||
)
|
||||
|
||||
payload = {
|
||||
"message_id": str(uuid.uuid4()),
|
||||
@@ -99,6 +128,8 @@ async def test_safety_file_body_uses_exact_attachment_schema() -> None:
|
||||
"attachment": {
|
||||
"attachment_id": str(attachment_id),
|
||||
"quarantine_object_key": "quarantine/users/u/file",
|
||||
"quarantine_version_id": "version-1",
|
||||
"quarantine_etag": '"etag-1"',
|
||||
"mime_type": "application/pdf",
|
||||
"size_bytes": 42,
|
||||
"checksum": "sha256:" + "a" * 64,
|
||||
@@ -121,6 +152,47 @@ async def test_safety_auth_failure_is_dependency_failure() -> None:
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_safety_poll_uses_location_and_rejects_untrusted_location() -> None:
|
||||
task_id = str(uuid.uuid4())
|
||||
|
||||
async def handler(request: httpx.Request) -> httpx.Response:
|
||||
assert request.url.path == f"/internal/safety/v2/messages/tasks/{task_id}"
|
||||
return httpx.Response(
|
||||
403,
|
||||
json={
|
||||
"verdict": "deny",
|
||||
"processing_mode": "standard",
|
||||
"config_version": 2,
|
||||
"rules_version": "2026-08-06",
|
||||
"rule_id": "file.malware_detected",
|
||||
"reason_code": "message_blocked",
|
||||
},
|
||||
)
|
||||
|
||||
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http:
|
||||
client = SafetyClient(settings(), http)
|
||||
result = await client.poll(
|
||||
f"/internal/safety/v2/messages/tasks/{task_id}", "request-1"
|
||||
)
|
||||
assert result["_status"] == 403
|
||||
with pytest.raises(DependencyFailure, match="invalid_safety_location"):
|
||||
await client.poll("https://attacker.example/task-1", "request-1")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_safety_fails_closed_on_malformed_success() -> None:
|
||||
async def handler(_: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(200, json={"verdict": "allow"})
|
||||
|
||||
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http:
|
||||
with pytest.raises(DependencyFailure, match="invalid_safety_response"):
|
||||
await SafetyClient(settings(), http).check(
|
||||
{"message_id": str(uuid.uuid4()), "content_kind": "text", "text": "hello"},
|
||||
"request-1",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_openlines_contract_uses_bearer_and_idempotency() -> None:
|
||||
message_id = uuid.uuid4()
|
||||
|
||||
Reference in New Issue
Block a user