/
systemsstrategyy
/
Treker
Обзор
Документация
Войти
/
systemsstrategyy
/
Treker
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
master
api/modules/auth/models.py
337 строк
16 KB
SystemsStrategy
Синхронизация с актуальной линией разработки (август 2026)
06 авг 2026, 12:52
06 авг 2026, 12:52
19af6aa
Код
Авторство
О чём код?
"""ORM-модели auth-схемы. - Organization — тенант (одна компания/команда). - User — пользователь, принадлежит организации; email уникален в рамках организации, не глобально (это позволяет одному человеку быть в нескольких организациях с разными адресами). - RefreshTokenFamily — token-family для refresh-rotation. Каждая успешная аутентификация создаёт новую family; каждый /refresh вращает в её рамках. Reuse использованного refresh = compromise → revoke всей family. """ from __future__ import annotations from datetime import datetime from sqlalchemy import ( BigInteger, Boolean, CheckConstraint, Column, DateTime, ForeignKey, String, Table, UniqueConstraint, func, ) from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.orm import Mapped, mapped_column, relationship from api.shared.db.base import Base, TimestampMixin, UUIDMixin _ROLE_CHECK = "role IN ('admin', 'teamlead', 'member', 'viewer')" # Ассоциативная таблица user_group_members — связь many-to-many. # Не используем ORM-класс, потому что нет своих fields кроме added_at. user_group_members = Table( "user_group_members", Base.metadata, Column( "user_id", BigInteger, ForeignKey("auth.users.id", ondelete="CASCADE"), primary_key=True, ), Column( "group_id", BigInteger, ForeignKey("auth.user_groups.id", ondelete="CASCADE"), primary_key=True, ), Column( "added_at", DateTime(timezone=True), server_default=func.now(), nullable=False, ), schema="auth", ) class Organization(UUIDMixin, TimestampMixin, Base): """Тенант. При регистрации первого юзера создаётся новая Organization, юзер становится её admin (RBAC — Day 4).""" __tablename__ = "organizations" __table_args__ = {"schema": "auth"} id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) name: Mapped[str] = mapped_column(String(200), nullable=False) slug: Mapped[str] = mapped_column(String(100), nullable=False, unique=True) users: Mapped[list[User]] = relationship( back_populates="organization", cascade="all, delete-orphan", ) class User(UUIDMixin, TimestampMixin, Base): """Пользователь приложения. email уникален в рамках организации (UNIQUE(org_id, email)) — один человек может состоять в нескольких организациях с разными email-адресами. password_hash хранит bcrypt-хеш; plain password нигде не сохраняется. """ __tablename__ = "users" __table_args__ = ( UniqueConstraint("organization_id", "email", name="uq_users_organization_id_email"), CheckConstraint(_ROLE_CHECK, name="ck_users_role"), {"schema": "auth"}, ) id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) organization_id: Mapped[int] = mapped_column( BigInteger, ForeignKey("auth.organizations.id", ondelete="CASCADE"), nullable=False, index=True, ) email: Mapped[str] = mapped_column(String(320), nullable=False) password_hash: Mapped[str] = mapped_column(String(255), nullable=False) full_name: Mapped[str] = mapped_column(String(200), nullable=False, default="") is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) email_verified_at: Mapped[datetime | None] = mapped_column( DateTime(timezone=True), nullable=True ) # Момент последней смены пароля (password-reset confirm). Служит # token-version'ом: access-токен с iat раньше этой метки считается # отозванным (get_current_user отклоняет). Access-токены нигде не # персистятся, поэтому занести их jti в denylist нельзя — сравнение по # времени выпуска единственный способ инвалидировать уже выданный access # при сбросе пароля. NULL = пароль ни разу не сбрасывался (все токены # валидны по denylist/exp как раньше). password_changed_at: Mapped[datetime | None] = mapped_column( DateTime(timezone=True), nullable=True ) # Дефолтная роль 'member'; admin/teamlead назначаются отдельно сервисом. role: Mapped[str] = mapped_column( String(20), nullable=False, default="member", server_default="member" ) # Telegram chat_id для пуш-уведомлений (привязывается юзером вручную; # NULL = telegram-канал недоступен). String — chat_id может быть > 2^53. telegram_chat_id: Mapped[str | None] = mapped_column(String(32), nullable=True) # Платформенный суперадмин (поставщик SaaS): управляет подписками ЛЮБОЙ # организации (billing-override). Ортогонально role='admin' (власть внутри # своей орг). Default False — выставляется вручную в БД, не через API. is_superuser: Mapped[bool] = mapped_column( Boolean, nullable=False, default=False, server_default="false" ) # Согласие на обработку ПДн (152-ФЗ): версия документа + момент акцепта. # Nullable — grandfather для юзеров, созданных до внедрения (как # email_verified_at); backfill не делаем. consent_version: Mapped[str | None] = mapped_column(String(32), nullable=True) consent_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) organization: Mapped[Organization] = relationship(back_populates="users") refresh_token_families: Mapped[list[RefreshTokenFamily]] = relationship( back_populates="user", cascade="all, delete-orphan", ) groups: Mapped[list[UserGroup]] = relationship( secondary=user_group_members, back_populates="members", ) class UserGroup(UUIDMixin, TimestampMixin, Base): """Логическое объединение юзеров внутри организации (отдел, команда). Несёт собственную роль — все участники получают её permissions ДОПОЛНИТЕЛЬНО к своей личной. Effective permissions = user.role ∪ all group.role.""" __tablename__ = "user_groups" __table_args__ = ( UniqueConstraint("organization_id", "name", name="uq_user_groups_organization_id_name"), CheckConstraint(_ROLE_CHECK, name="ck_user_groups_role"), {"schema": "auth"}, ) id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) organization_id: Mapped[int] = mapped_column( BigInteger, ForeignKey("auth.organizations.id", ondelete="CASCADE"), nullable=False, index=True, ) name: Mapped[str] = mapped_column(String(200), nullable=False) role: Mapped[str] = mapped_column( String(20), nullable=False, default="member", server_default="member" ) organization: Mapped[Organization] = relationship() members: Mapped[list[User]] = relationship( secondary=user_group_members, back_populates="groups", ) class EmailVerificationToken(Base): """Одноразовый токен для верификации email при регистрации. Plain token отправляется пользователю в письме, в БД хранится только SHA256-хеш. После клика по ссылке `consumed_at` ставится в now(), повторный consume отвергается. TTL 24 часа.""" __tablename__ = "email_verification_tokens" __table_args__ = {"schema": "auth"} id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) user_id: Mapped[int] = mapped_column( BigInteger, ForeignKey("auth.users.id", ondelete="CASCADE"), nullable=False, index=True, ) token_hash: Mapped[str] = mapped_column(String(64), nullable=False, unique=True) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now(), nullable=False ) expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) consumed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) class PasswordResetToken(Base): """Одноразовый токен для сброса пароля. Структурно идентичен EmailVerificationToken, но отдельная таблица для семантики и независимых retention/security policy. TTL 1 час.""" __tablename__ = "password_reset_tokens" __table_args__ = {"schema": "auth"} id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) user_id: Mapped[int] = mapped_column( BigInteger, ForeignKey("auth.users.id", ondelete="CASCADE"), nullable=False, index=True, ) token_hash: Mapped[str] = mapped_column(String(64), nullable=False, unique=True) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now(), nullable=False ) expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) consumed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) class RefreshTokenFamily(Base): """Цепочка refresh-токенов одной сессии (ADR-0003). При каждом /refresh выдаётся новый refresh-токен внутри той же family. Если использованный refresh приходит повторно — вся family revoke'ается: значит токен утёк, дальнейшая ротация злонамеренной стороной невозможна.""" __tablename__ = "refresh_token_families" __table_args__ = {"schema": "auth"} id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) user_id: Mapped[int] = mapped_column( BigInteger, ForeignKey("auth.users.id", ondelete="CASCADE"), nullable=False, index=True, ) # UUID самой family — кладётся в JWT-claim, по нему ищем при /refresh. family_uid: Mapped[str] = mapped_column(String(36), nullable=False, unique=True) # JTI текущего активного refresh-токена. На каждом /refresh обновляется. current_jti: Mapped[str] = mapped_column(String(36), nullable=False) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now(), nullable=False ) last_used_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now(), nullable=False ) revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) revoked_reason: Mapped[str | None] = mapped_column(String(50), nullable=True) user: Mapped[User] = relationship(back_populates="refresh_token_families") class Invitation(UUIDMixin, TimestampMixin, Base): """Pending приглашение присоединиться к существующей организации. Admin создаёт invite через POST /auth/invitations с email + role. Backend возвращает URL-safe `token` для public lookup. Приглашаемый кликает `/register?invite={token}` → resolve org-info по token → завершает register'аем (только password + full_name; email из invite'а, org-context из FK). Lifecycle: - pending: created, accepted_at=NULL, revoked_at=NULL, expires_at>now - accepted: accepted_at установлен (повторный accept = 404) - revoked: admin отозвал до accept'а - expired: expires_at < now (service возвращает 404 при попытке use) """ __tablename__ = "invitations" __table_args__ = ( UniqueConstraint("token", name="uq_invitations_token"), CheckConstraint(_ROLE_CHECK, name="ck_invitations_role"), # Partial unique через миграцию a018 (alembic не поддерживает в declarative): # `WHERE accepted_at IS NULL AND revoked_at IS NULL`. {"schema": "auth"}, ) id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) organization_id: Mapped[int] = mapped_column( BigInteger, ForeignKey("auth.organizations.id", ondelete="CASCADE"), nullable=False, ) email: Mapped[str] = mapped_column(String(254), nullable=False) role: Mapped[str] = mapped_column(String(20), nullable=False) # Token URL-safe random (см. secrets.token_urlsafe(32) в service). # Не uid модели — uid угадывается, token должен быть unpredictable. token: Mapped[str] = mapped_column(String(64), nullable=False, unique=True, index=True) invited_by: Mapped[int | None] = mapped_column( BigInteger, ForeignKey("auth.users.id", ondelete="SET NULL"), nullable=True, ) expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) accepted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) class CookieConsent(UUIDMixin, Base): """Факт согласия на использование cookie (российское регулирование). Хранится для аудита (доказательство получения согласия). Для залогиненных заполняется `user_id`; для анонимов идентификатор — `anonymous_id` (UUID из cookie). `categories` — granular-согласие (necessary всегда true, analytics/marketing — опционально). `source` различает Трекер и лендинг. Запись неизменяемая (append-only): новое согласие = новая строка.""" __tablename__ = "cookie_consents" __table_args__ = {"schema": "auth"} id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) # SET NULL: при удалении юзера аудит-запись согласия сохраняется (обезличенно). user_id: Mapped[int | None] = mapped_column( BigInteger, ForeignKey("auth.users.id", ondelete="SET NULL"), nullable=True, index=True, ) anonymous_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True) policy_version: Mapped[str] = mapped_column(String(32), nullable=False) categories: Mapped[dict[str, bool]] = mapped_column(JSONB, nullable=False, server_default="{}") source: Mapped[str] = mapped_column( String(20), nullable=False, default="tracker", server_default="tracker" ) ip: Mapped[str | None] = mapped_column(String(45), nullable=True) # IPv6-safe user_agent: Mapped[str | None] = mapped_column(String(400), nullable=True) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now(), nullable=False )