Add pilots: invite-only products with per-user RDP slots

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>
This commit is contained in:
2026-10-02 06:32:05 +00:00
parent 9e525a44c3
commit 46e5b5a41e
8 changed files with 327 additions and 31 deletions
+16
View File
@@ -54,6 +54,12 @@ class Service(Base):
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):
@@ -83,6 +89,11 @@ class UserServiceAccess(Base):
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):
@@ -94,6 +105,11 @@ class RdpSlot(Base):
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):