Files
smart-org/apps/api/app/domain/audit/models.py
T
mjalmoudy94 8eb631aedf ساختار مالکیت پلتفرم: یک مالک، مدیرانِ ساختهٔ مالک، سازمان‌های مدیریت‌شده
- users.is_platform_owner → users.platform_role (owner|manager|user) با مهاجرت
  d4e5f6a7b8c9 و backfill. ایندکس یکتای جزئی uq_single_platform_owner تضمین
  می‌کند دقیقاً یک مالک وجود داشته باشد — در سطح دیتابیس، نه فقط در کد.
- ثبت‌نام عمومی حذف شد (POST /auth/signup). حساب‌ها فقط با POST /admin/users
  (فقط مالک، با رمز موقت یک‌بارمصرف) یا با پذیرش لینک دعوت ساخته می‌شوند.
  در مسیر دعوت، اگر حساب وجود نداشته باشد خودِ دعوت‌شده رمز تعیین می‌کند؛ اگر
  وجود داشته باشد لینک هرگز جای reset رمز را نمی‌گیرد.
- ساخت سازمان فقط برای مالک و مدیران؛ کاربر عادی با دعوت عضو می‌شود.
- تنزل یا حذف مدیر، سازمان‌هایش را به مالک سامانه منتقل می‌کند (مالکیت + عضویت،
  با audit). حساب مالک از API تغییرناپذیر است؛ جابه‌جایی صندلی فقط با
  POST /admin/transfer-ownership و در یک تراکنش انجام می‌شود.
- افزودن POST /auth/change-password برای تبدیل رمز موقت به رمز خصوصی.
- فرانت: صفحهٔ /admin/users، صفحهٔ /account، بازنویسی /accept-invite، حذف /signup،
  گارد /onboarding برای کاربر عادی، و نشان نقش پلتفرم در پوستهٔ اپ.
- PRAGMA foreign_keys=ON برای SQLite تا ON DELETE CASCADE در dev و prod یکسان
  رفتار کند (پیش‌تر حذف کاربر، عضویت‌هایش را یتیم می‌گذاشت).
- تست: ۳۰۰ تست بک‌اند (شامل tests/test_platform_hierarchy.py با ۲۲ تست) و ۶۴ تست
  فرانت‌اند؛ ruff، mypy، tsc، eslint و build تمیز.
2026-10-13 21:58:41 +03:30

174 lines
6.0 KiB
Python

"""Observability & Audit domain: the immutable trail of who did what."""
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime
from app.domain.base import new_id, utcnow
class AuditAction:
"""Well-known auditable actions. Free-form strings are allowed but these are
the ones the application layer emits so far."""
LOGIN = "auth.login"
LOGOUT = "auth.logout"
SIGNUP = "auth.signup"
#: A user changed their own password (or an owner-forced temp password was
#: replaced). Auditable because it is the one action that can lock the
#: legitimate owner out.
PASSWORD_CHANGE = "auth.password_change"
ORG_CREATE = "organization.create"
ORG_DELETE = "organization.delete"
#: An organization changed hands because its owning manager was demoted or
#: deleted — the platform owner takes over. Never automatic for any other
#: reason.
ORG_OWNER_TRANSFER = "organization.owner_transfer"
MEMBER_INVITE = "member.invite"
#: An invited user redeemed their token and joined the organization.
MEMBER_JOIN = "member.join"
MEMBER_ROLE_CHANGE = "member.role_change"
MEMBER_REMOVE = "member.remove"
#: The platform owner created an account (manager or plain user).
USER_CREATE = "user.create"
#: A user's platform role changed (user <-> manager, or manager -> user).
USER_PLATFORM_ROLE_CHANGE = "user.platform_role_change"
#: An account was activated or deactivated.
USER_ACTIVE_CHANGE = "user.active_change"
#: An account was deleted by the platform owner.
USER_DELETE = "user.delete"
#: The single platform-owner seat moved to another account.
OWNERSHIP_TRANSFER = "platform.ownership_transfer"
EMPLOYEE_CREATE = "employee.create"
EMPLOYEE_UPDATE = "employee.update"
REPO_CREATE = "repo.create"
REPO_DELETE = "repo.delete"
DOMAIN_CREATE = "domain.create"
DOMAIN_DELETE = "domain.delete"
SANDBOX_COMMAND = "sandbox.command"
SANDBOX_DESTROY = "sandbox.destroy"
#: A platform worker stopped a sandbox session that had gone idle. System
#: action, not a tenant one — the actor is the platform, not a user.
SANDBOX_REAP = "sandbox.reap"
APPROVAL_DECISION = "approval.decision"
BILLING_CREDIT = "billing.credit"
BILLING_PAYMENT = "billing.payment"
PROVIDER_CHANGE = "provider.change"
MODEL_CHANGE = "model.change"
ROUTING_CHANGE = "routing.change"
PROJECT_CREATE = "project.create"
PROJECT_DELETE = "project.delete"
TASK_CREATE = "task.create"
RUN_CANCEL = "run.cancel"
#: A platform owner triaged an error in the console. Auditable so "who
#: decided this bug was fixed?" has an answer.
ERROR_RESOLVE = "error.resolve"
@dataclass(slots=True, kw_only=True)
class AuditEvent:
action: str
organization_id: str | None = None
actor_user_id: str | None = None
target_type: str = ""
target_id: str = ""
metadata_json: dict = field(default_factory=dict)
ip_address: str = ""
user_agent: str = ""
id: str = field(default_factory=new_id)
created_at: datetime = field(default_factory=utcnow)
@dataclass(slots=True, kw_only=True)
class Trace:
organization_id: str
name: str = ""
run_id: str | None = None
id: str = field(default_factory=new_id)
created_at: datetime = field(default_factory=utcnow)
@dataclass(slots=True, kw_only=True)
class Span:
trace_id: str
name: str
kind: str = "internal"
parent_span_id: str | None = None
attributes_json: dict = field(default_factory=dict)
id: str = field(default_factory=new_id)
started_at: datetime = field(default_factory=utcnow)
ended_at: datetime | None = None
@property
def duration_ms(self) -> int | None:
if self.ended_at is None:
return None
return int((self.ended_at - self.started_at).total_seconds() * 1000)
@dataclass(slots=True, kw_only=True)
class ErrorEvent:
"""A persisted failure, recorded so it can be diagnosed later.
Deliberately *not* a domain aggregate: nothing in the business rules depends
on it. It is an operational record, which is why it carries transport-level
detail (method, path, status code) that no domain model would ever hold.
"""
kind: str
message: str
organization_id: str | None = None
run_id: str | None = None
stack: str = ""
metadata_json: dict = field(default_factory=dict)
#: Where the failure happened: ``http``, ``worker`` or ``startup``.
source: str = "http"
#: ``error`` for anything unexpected, ``warning`` for expected 4xx noise,
#: ``critical`` for startup/infrastructure failures.
severity: str = "error"
#: Exception class name (``ValueError``, ``IntegrityError``, …). Empty when
#: the failure was not an exception.
error_type: str = ""
#: Stable hash of the failure's identity, so a thousand occurrences of the
#: same bug group into one row in the console instead of a thousand.
fingerprint: str = ""
status_code: int | None = None
method: str = ""
path: str = ""
user_id: str | None = None
#: Correlates this record with the response the caller received and with
#: every log line written while handling the same request.
request_id: str = ""
ip: str = ""
user_agent: str = ""
resolved_at: datetime | None = None
resolved_note: str = ""
id: str = field(default_factory=new_id)
created_at: datetime = field(default_factory=utcnow)
class ErrorSource:
"""Where an error was caught. Kept as strings so a new source needs no
migration."""
HTTP = "http"
WORKER = "worker"
STARTUP = "startup"
class ErrorSeverity:
ERROR = "error"
WARNING = "warning"
CRITICAL = "critical"
@dataclass(slots=True, kw_only=True)
class QualityMetric:
organization_id: str
name: str
value: float
dimensions_json: dict = field(default_factory=dict)
id: str = field(default_factory=new_id)
created_at: datetime = field(default_factory=utcnow)