- 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 تمیز.
174 lines
6.0 KiB
Python
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)
|