46e5b5a41e
New Service.is_pilot flag - a pilot is a Service (type RDP) that: - is excluded from /api/public/services-by-category and /api/request-more-access (admin-granted only, no self-service) - still shows as a locked card on the dashboard for users without access (Доступно по запросу section), but with a По приглашению badge instead of the self-request button/flow - gets its own non-clickable teaser row on the public /login page (logos only, informational) RdpSlot.assigned_user_id (nullable) - pilot slots are bound to one specific user instead of being drawn from the shared pool; regular RDP services are unaffected (field stays NULL, same pool behaviour as before). The /go/ allocator branches on service.is_pilot to pick the caller's assigned slot instead of any free one. Slots release automatically (cleanup_loop) when the owning grant is revoked or expires, and immediately on manual ACL revoke. UserServiceAccess.expires_at (nullable) - per-grant access window, used by pilots so their access can be shorter than the account's own expires_at; NULL (unchanged default) means "follow the account". has_access() and the dashboard's granted/locked split both honour it. Admin UI: "Это пилот" checkbox on the RDP service form, an assign-user dropdown on a pilot's slot table (replaces the occupied-by column), and a per-pilot expiry date field in the user ACL grid. Schema is applied via the existing ensure_schema_compatibility() idempotent ALTER TABLE pattern (no alembic in this project) - no manual migration step needed, it runs at container startup. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
156 lines
7.8 KiB
Python
156 lines
7.8 KiB
Python
import datetime as dt
|
|
import enum
|
|
from typing import Optional
|
|
|
|
from sqlalchemy import (
|
|
Boolean, DateTime, Enum, ForeignKey, Integer, String, Text, UniqueConstraint,
|
|
)
|
|
from sqlalchemy.orm import Mapped, mapped_column
|
|
|
|
from database import Base
|
|
|
|
|
|
class ServiceType(str, enum.Enum):
|
|
WEB = "WEB"
|
|
VNC = "VNC"
|
|
RDP = "RDP"
|
|
|
|
|
|
class SessionStatus(str, enum.Enum):
|
|
ACTIVE = "ACTIVE"
|
|
EXPIRED = "EXPIRED"
|
|
TERMINATED = "TERMINATED"
|
|
ROTATED = "ROTATED"
|
|
|
|
|
|
class User(Base):
|
|
__tablename__ = "users"
|
|
|
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
|
username: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
|
password_hash: Mapped[str] = mapped_column(String(255))
|
|
expires_at: Mapped[dt.datetime] = mapped_column(DateTime(timezone=True), index=True)
|
|
active: Mapped[bool] = mapped_column(Boolean, default=True, index=True)
|
|
is_admin: Mapped[bool] = mapped_column(Boolean, default=False)
|
|
first_name: Mapped[str] = mapped_column(String(64), default="")
|
|
last_name: Mapped[str] = mapped_column(String(64), default="")
|
|
created_at: Mapped[dt.datetime] = mapped_column(DateTime(timezone=True), default=lambda: dt.datetime.now(dt.timezone.utc))
|
|
|
|
|
|
class Service(Base):
|
|
__tablename__ = "services"
|
|
|
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
|
name: Mapped[str] = mapped_column(String(128))
|
|
slug: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
|
type: Mapped[ServiceType] = mapped_column(Enum(ServiceType), index=True)
|
|
target: Mapped[str] = mapped_column(Text)
|
|
comment: Mapped[str] = mapped_column(Text, default="")
|
|
svc_login: Mapped[str] = mapped_column(String(256), default="")
|
|
svc_password: Mapped[str] = mapped_column(String(256), default="")
|
|
svc_cred_hint: Mapped[str] = mapped_column(Text, default="")
|
|
icon_path: Mapped[str] = mapped_column(Text, default="")
|
|
seo_description: Mapped[str] = mapped_column(Text, default="")
|
|
active: Mapped[bool] = mapped_column(Boolean, default=True)
|
|
warm_pool_size: Mapped[int] = mapped_column(Integer, default=0)
|
|
created_at: Mapped[dt.datetime] = mapped_column(DateTime(timezone=True), default=lambda: dt.datetime.now(dt.timezone.utc))
|
|
# Pilot = invite-only product. Hidden from the public/request-more-access
|
|
# catalogs (admin grants access by hand instead of self-service), and its
|
|
# RdpSlot rows are bound to specific users (RdpSlot.assigned_user_id)
|
|
# rather than drawn from a shared pool. See _apply_access_decision /
|
|
# pilot slot allocation in main.py for where this flag is read.
|
|
is_pilot: Mapped[bool] = mapped_column(Boolean, default=False, index=True)
|
|
|
|
|
|
class Category(Base):
|
|
__tablename__ = "categories"
|
|
|
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
|
name: Mapped[str] = mapped_column(String(128), unique=True, index=True)
|
|
slug: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
|
created_at: Mapped[dt.datetime] = mapped_column(DateTime(timezone=True), default=lambda: dt.datetime.now(dt.timezone.utc))
|
|
|
|
|
|
class ServiceCategory(Base):
|
|
__tablename__ = "service_categories"
|
|
__table_args__ = (UniqueConstraint("service_id", "category_id", name="uq_service_category"),)
|
|
|
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
|
service_id: Mapped[int] = mapped_column(ForeignKey("services.id", ondelete="CASCADE"), index=True)
|
|
category_id: Mapped[int] = mapped_column(ForeignKey("categories.id", ondelete="CASCADE"), index=True)
|
|
created_at: Mapped[dt.datetime] = mapped_column(DateTime(timezone=True), default=lambda: dt.datetime.now(dt.timezone.utc))
|
|
|
|
|
|
class UserServiceAccess(Base):
|
|
__tablename__ = "user_service_access"
|
|
__table_args__ = (UniqueConstraint("user_id", "service_id", name="uq_user_service"),)
|
|
|
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
|
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
|
|
service_id: Mapped[int] = mapped_column(ForeignKey("services.id", ondelete="CASCADE"), index=True)
|
|
granted_at: Mapped[dt.datetime] = mapped_column(DateTime(timezone=True), default=lambda: dt.datetime.now(dt.timezone.utc))
|
|
# Per-grant expiry, used by pilots so a pilot's access window can be
|
|
# shorter than the user's overall account expires_at. NULL (the default,
|
|
# and the only value regular non-pilot grants ever get) means "follow
|
|
# the account's own expires_at" - see has_access() in auth.py.
|
|
expires_at: Mapped[Optional[dt.datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
|
|
|
|
|
class RdpSlot(Base):
|
|
__tablename__ = "rdp_slots"
|
|
|
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
|
service_id: Mapped[int] = mapped_column(ForeignKey("services.id", ondelete="CASCADE"), index=True)
|
|
rdp_username: Mapped[str] = mapped_column(String(128))
|
|
rdp_password: Mapped[str] = mapped_column(String(256), default="")
|
|
container_name: Mapped[Optional[str]] = mapped_column(String(128), nullable=True)
|
|
created_at: Mapped[dt.datetime] = mapped_column(DateTime(timezone=True), default=lambda: dt.datetime.now(dt.timezone.utc))
|
|
# Pilot slots are reserved for one specific person instead of being
|
|
# drawn from a shared pool - set only on slots that belong to a
|
|
# Service with is_pilot=True. NULL means "ordinary pooled slot",
|
|
# unchanged behaviour for every existing RDP service.
|
|
assigned_user_id: Mapped[Optional[int]] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
|
|
|
|
|
|
class SessionModel(Base):
|
|
__tablename__ = "sessions"
|
|
|
|
id: Mapped[str] = mapped_column(String(36), primary_key=True)
|
|
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
|
|
service_id: Mapped[int] = mapped_column(ForeignKey("services.id", ondelete="CASCADE"), index=True)
|
|
status: Mapped[SessionStatus] = mapped_column(Enum(SessionStatus), default=SessionStatus.ACTIVE, index=True)
|
|
created_at: Mapped[dt.datetime] = mapped_column(DateTime(timezone=True), default=lambda: dt.datetime.now(dt.timezone.utc), index=True)
|
|
last_access_at: Mapped[dt.datetime] = mapped_column(DateTime(timezone=True), default=lambda: dt.datetime.now(dt.timezone.utc), index=True)
|
|
container_id: Mapped[Optional[str]] = mapped_column(String(128), nullable=True)
|
|
|
|
|
|
class AuditLog(Base):
|
|
__tablename__ = "audit_logs"
|
|
|
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
|
user_id: Mapped[Optional[int]] = mapped_column(Integer, nullable=True, index=True)
|
|
action: Mapped[str] = mapped_column(String(128), index=True)
|
|
details: Mapped[str] = mapped_column(Text)
|
|
created_at: Mapped[dt.datetime] = mapped_column(DateTime(timezone=True), default=lambda: dt.datetime.now(dt.timezone.utc), index=True)
|
|
|
|
|
|
class PendingAccessRequest(Base):
|
|
__tablename__ = "pending_access_requests"
|
|
|
|
id: Mapped[str] = mapped_column(String(12), primary_key=True)
|
|
name: Mapped[str] = mapped_column(String(256))
|
|
company: Mapped[str] = mapped_column(String(256))
|
|
email: Mapped[str] = mapped_column(String(256))
|
|
phone: Mapped[str] = mapped_column(String(64))
|
|
manager: Mapped[str] = mapped_column(String(256), default="")
|
|
products_json: Mapped[str] = mapped_column(Text, default="[]")
|
|
portal_url: Mapped[str] = mapped_column(String(256), default="")
|
|
status: Mapped[str] = mapped_column(String(16), default="pending")
|
|
created_at: Mapped[dt.datetime] = mapped_column(DateTime(timezone=True), default=lambda: dt.datetime.now(dt.timezone.utc))
|
|
# Telegram delivery tracking: the notification is sent once when the
|
|
# request is created; if that attempt fails (tel.4mont.ru down/timeout),
|
|
# a background retry loop resends telegram_message every 5 minutes until
|
|
# it succeeds, without recomputing geo/IP each time.
|
|
telegram_notified: Mapped[bool] = mapped_column(Boolean, default=False)
|
|
telegram_message: Mapped[str] = mapped_column(Text, default="")
|