From 46e5b5a41ea4c2538ec2874d344a82add052c244 Mon Sep 17 00:00:00 2001 From: Ruslan Date: Fri, 2 Oct 2026 06:32:05 +0000 Subject: [PATCH] Add pilots: invite-only products with per-user RDP slots MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- app/auth.py | 17 +++- app/main.py | 168 +++++++++++++++++++++++++++++++---- app/maintenance.py | 26 +++++- app/models.py | 16 ++++ app/runtime.py | 7 ++ app/templates/admin.html | 82 +++++++++++++++-- app/templates/dashboard.html | 26 +++++- app/templates/login.html | 16 ++++ 8 files changed, 327 insertions(+), 31 deletions(-) diff --git a/app/auth.py b/app/auth.py index e6b0dbf..c5f80a5 100644 --- a/app/auth.py +++ b/app/auth.py @@ -95,11 +95,20 @@ def validate_csrf(request: Request) -> None: def has_access(db: Session, user_id: int, service_id: int) -> bool: - q = select(UserServiceAccess).where( - UserServiceAccess.user_id == user_id, - UserServiceAccess.service_id == service_id, + access = db.scalar( + select(UserServiceAccess).where( + UserServiceAccess.user_id == user_id, + UserServiceAccess.service_id == service_id, + ) ) - return db.scalar(q) is not None + if access is None: + return False + # A per-grant expires_at (used by pilots, whose access window can be + # shorter than the account's own expires_at) overrides the account + # expiry for this one product. NULL means "follow the account". + if access.expires_at is not None and access.expires_at <= now_utc(): + return False + return True import threading import time diff --git a/app/main.py b/app/main.py index 4ebd404..43565a2 100644 --- a/app/main.py +++ b/app/main.py @@ -980,9 +980,20 @@ async def startup_event(): def _login_wall_services(db: Session): - """Active services shown as a logo wall on the public login page.""" + """Active, non-pilot services shown as a logo wall on the public login + page - the self-service "pick a product" catalog. Pilots are invite-only + and get their own separate, non-clickable teaser (_login_wall_pilots).""" return db.scalars( - select(Service).where(Service.active == True).order_by(Service.name) + select(Service).where(Service.active == True, Service.is_pilot == False).order_by(Service.name) + ).all() + + +def _login_wall_pilots(db: Session): + """Active pilot services shown as a purely informational logo row on the + public login page - awareness only, not part of the self-service catalog + (no request-access entry point; admin grants these by hand).""" + return db.scalars( + select(Service).where(Service.active == True, Service.is_pilot == True).order_by(Service.name) ).all() @@ -1013,6 +1024,7 @@ def index(request: Request, user: Optional[User] = Depends(get_current_user), db "login_error": "", "session_notice": session_notice, "public_services": _login_wall_services(db), + "public_pilots": _login_wall_pilots(db), }, ) response.set_cookie(CSRF_COOKIE, csrf, httponly=False, secure=True, samesite="lax", path="/") @@ -1023,11 +1035,21 @@ def index(request: Request, user: Optional[User] = Depends(get_current_user), db .where(Service.active == True, Service.type.in_([ServiceType.WEB, ServiceType.RDP])) .order_by(Service.name) ).all() + # Per-grant expires_at (used by pilots) can make a grant expired even + # though the row still exists and the account itself is still valid - + # exclude those rows here so an expired pilot grant falls back into + # locked_services instead of staying "granted". granted_ids = set( - db.scalars(select(UserServiceAccess.service_id).where(UserServiceAccess.user_id == user.id)).all() + db.scalars( + select(UserServiceAccess.service_id).where( + UserServiceAccess.user_id == user.id, + (UserServiceAccess.expires_at.is_(None)) | (UserServiceAccess.expires_at > now_utc()), + ) + ).all() ) services = [svc for svc in all_services if svc.id in granted_ids] locked_services = [svc for svc in all_services if svc.id not in granted_ids] + pilot_ids = {svc.id for svc in all_services if svc.is_pilot} # Categories are computed across the whole catalog (granted + locked) so # the nav lets a user browse into a category they don't have access to @@ -1085,6 +1107,7 @@ def index(request: Request, user: Optional[User] = Depends(get_current_user), db "user": user, "services": services, "locked_services": locked_services, + "pilot_ids": pilot_ids, "categories": categories, "category_counts": category_counts, "total_catalog_count": len(all_services), @@ -1126,6 +1149,7 @@ def admin_page(request: Request, admin: User = Depends(require_admin), db: Sessi services = db.scalars(select(Service).where(Service.type.in_([ServiceType.WEB, ServiceType.RDP])).order_by(Service.id)).all() web_services = [s for s in services if s.type == ServiceType.WEB] rdp_services = [s for s in services if s.type == ServiceType.RDP] + pilot_service_ids = {s.id for s in services if s.is_pilot} service_category_map = {s.id: [] for s in services} if services: service_rows = db.execute( @@ -1137,8 +1161,11 @@ def admin_page(request: Request, admin: User = Depends(require_admin), db: Sessi service_category_map.setdefault(service_id, []).append(category_id) acl_rows = db.scalars(select(UserServiceAccess)).all() acl = {} + acl_expires = {} for row in acl_rows: acl.setdefault(row.user_id, []).append(row.service_id) + if row.expires_at is not None: + acl_expires.setdefault(row.user_id, {})[row.service_id] = row.expires_at.isoformat() for user_id in acl: acl[user_id] = sorted(acl[user_id]) pool_status = {s.id: get_pool_status_for_service(s) for s in services} @@ -1225,12 +1252,18 @@ def admin_page(request: Request, admin: User = Depends(require_admin), db: Sessi if active_sess: u = db.get(User, active_sess.user_id) occupied_username = u.username if u else f"id={active_sess.user_id}" + assigned_username = None + if slot.assigned_user_id: + au = db.get(User, slot.assigned_user_id) + assigned_username = au.username if au else f"id={slot.assigned_user_id}" slot_list.append({ "id": slot.id, "rdp_username": slot.rdp_username, "container_name": slot.container_name or "", "running": running, "occupied_username": occupied_username, + "assigned_user_id": slot.assigned_user_id, + "assigned_username": assigned_username, }) rdp_slots[svc.id] = slot_list return templates.TemplateResponse( @@ -1239,12 +1272,18 @@ def admin_page(request: Request, admin: User = Depends(require_admin), db: Sessi "request": request, "admin": admin, "users": users, + "users_json": [ + {"id": u.id, "label": (f"{u.first_name} {u.last_name}".strip() or u.username) + f" ({u.username})"} + for u in users + ], "web_services": web_services, "rdp_services": rdp_services, + "pilot_service_ids": pilot_service_ids, "services": services, "categories": categories, "service_category_map": service_category_map, "acl": acl, + "acl_expires": acl_expires, "pool_status": pool_status, "service_health": service_health, "web_totals": web_totals, @@ -1593,8 +1632,10 @@ def sitemap_xml(db: Session = Depends(get_db)): @app.get("/api/public/services-by-category") def public_services_by_category(db: Session = Depends(get_db)): + # Pilots are invite-only - admin grants them by hand, so they must not + # be selectable from the self-service "request access" form. services = db.execute( - select(Service).where(Service.active == True).order_by(Service.name) + select(Service).where(Service.active == True, Service.is_pilot == False).order_by(Service.name) ).scalars().all() categories = db.execute(select(Category).order_by(Category.name)).scalars().all() cat_map = {c.id: c.name for c in categories} @@ -1830,10 +1871,13 @@ async def request_more_access( ).all() } from sqlalchemy import func as _func4 + # is_pilot excluded even if the caller bypasses the UI and posts a pilot's + # name directly - pilots are admin-granted only, never self-service. matched = db.scalars( select(Service).where( _func4.lower(Service.name).in_([p.lower() for p in requested]), Service.active == True, + Service.is_pilot == False, ) ).all() products = [svc.name for svc in matched if svc.name.lower() not in already_granted] @@ -1919,6 +1963,7 @@ def login( "login_error": "Неверный логин или пароль", "session_notice": "", "public_services": _login_wall_services(db), + "public_pilots": _login_wall_pilots(db), }, status_code=401, ) @@ -1934,6 +1979,7 @@ def login( "login_error": "Доступ к сервису приостоновлен, обратитесь к вашему менеджеру", "session_notice": "", "public_services": _login_wall_services(db), + "public_pilots": _login_wall_pilots(db), }, status_code=403, ) @@ -2069,13 +2115,34 @@ def go_service( busy_slot_ids.add(int(row.container_id.split(":", 1)[1])) except Exception: pass - free_slot = next((s for s in slots if s.id not in busy_slot_ids), None) - if not free_slot: - _emit("rdp_all_slots_busy") - raise HTTPException( - status_code=503, - detail="Все слоты этого RDP сервиса заняты. Попробуйте позже.", + if service.is_pilot: + # Pilot slots are reserved per person (admin + # assigns the container in advance), never + # picked from a shared pool - the earlier + # existing_user_session check above already + # resumes an active session on this slot, so + # reaching here with it "busy" would mean two + # concurrent launches; treat that the same as + # "no slot available" rather than silently + # handing the user a different pilot's machine. + free_slot = next( + (s for s in slots if s.assigned_user_id == user.id and s.id not in busy_slot_ids), + None, ) + if not free_slot: + _emit("pilot_slot_not_assigned") + raise HTTPException( + status_code=403, + detail="Вам не назначен слот для этого пилота. Обратитесь к администратору.", + ) + else: + free_slot = next((s for s in slots if s.id not in busy_slot_ids), None) + if not free_slot: + _emit("rdp_all_slots_busy") + raise HTTPException( + status_code=503, + detail="Все слоты этого RDP сервиса заняты. Попробуйте позже.", + ) session_obj = SessionModel( id=session_id, user_id=user.id, @@ -2609,6 +2676,7 @@ def create_service(payload: dict, request: Request, _: User = Depends(require_ad svc_cred_hint=payload.get("svc_cred_hint", ""), active=payload.get("active", True), warm_pool_size=max(0, int(payload.get("warm_pool_size", 0))), + is_pilot=bool(payload.get("is_pilot", False)), ) db.add(service) db.flush() @@ -2671,7 +2739,7 @@ def edit_service(service_id: int, payload: dict, request: Request, _: User = Dep service = db.get(Service, service_id) if not service: raise HTTPException(status_code=404, detail="Service not found") - for key in ["name", "slug", "target", "active", "comment", "svc_login", "svc_password", "svc_cred_hint"]: + for key in ["name", "slug", "target", "active", "comment", "svc_login", "svc_password", "svc_cred_hint", "is_pilot"]: if key in payload: setattr(service, key, payload[key]) if "type" in payload: @@ -2766,6 +2834,48 @@ def delete_rdp_slot(slot_id: int, request: Request, _: User = Depends(require_ad return {"ok": True} +@app.put("/api/admin/rdp-slots/{slot_id}/assign") +def assign_rdp_slot(slot_id: int, payload: dict, request: Request, _: User = Depends(require_admin), db: Session = Depends(get_db)): + """Bind (or unbind, with user_id null) a pilot's RDP slot to one + specific person. Only meaningful for slots that belong to a pilot + service - a slot on a regular pooled RDP service doesn't need this, + since any free slot in the pool already works for anyone with access.""" + validate_csrf(request) + slot = db.get(RdpSlot, slot_id) + if not slot: + raise HTTPException(status_code=404, detail="Slot not found") + service = db.get(Service, slot.service_id) + if not service or not service.is_pilot: + raise HTTPException(status_code=400, detail="Слот принадлежит не пилотному сервису") + + raw_user_id = payload.get("user_id") + if raw_user_id in (None, ""): + slot.assigned_user_id = None + db.commit() + audit(db, "RDP_SLOT_UNASSIGN", f"service={service.slug} slot={slot.id}", user_id=None) + return {"ok": True, "assigned_user_id": None} + + target_user = db.get(User, int(raw_user_id)) + if not target_user: + raise HTTPException(status_code=404, detail="User not found") + other = db.scalar( + select(RdpSlot).where( + RdpSlot.service_id == service.id, + RdpSlot.assigned_user_id == target_user.id, + RdpSlot.id != slot.id, + ) + ) + if other: + raise HTTPException( + status_code=409, + detail=f"У пользователя уже есть слот №{other.id} на этом пилоте", + ) + slot.assigned_user_id = target_user.id + db.commit() + audit(db, "RDP_SLOT_ASSIGN", f"service={service.slug} slot={slot.id} user={target_user.username}", user_id=None) + return {"ok": True, "assigned_user_id": target_user.id} + + @app.post("/api/admin/categories") def create_category(payload: dict, request: Request, _: User = Depends(require_admin), db: Session = Depends(get_db)): validate_csrf(request) @@ -2860,16 +2970,44 @@ def set_acl(user_id: int, payload: dict, request: Request, _: User = Depends(req if not user: raise HTTPException(status_code=404, detail="User not found") service_ids = set(payload.get("service_ids", [])) + # Optional per-service expiry override, e.g. {"12": "2026-11-01T00:00:00+00:00"} + # or {"12": null} to clear it back to "follow the account expiry". + # Used for pilots, whose access window can be shorter than the account's. + expires_by_service = payload.get("expires_at_by_service") or {} existing = db.scalars(select(UserServiceAccess).where(UserServiceAccess.user_id == user_id)).all() existing_map = {x.service_id: x for x in existing} + rows_by_service = dict(existing_map) for sid in service_ids: if sid not in existing_map: - db.add(UserServiceAccess(user_id=user_id, service_id=sid)) - for sid, row in existing_map.items(): - if sid not in service_ids: - db.delete(row) + new_row = UserServiceAccess(user_id=user_id, service_id=sid) + db.add(new_row) + rows_by_service[sid] = new_row + removed_ids = [sid for sid, row in existing_map.items() if sid not in service_ids] + for sid in removed_ids: + db.delete(existing_map[sid]) + + for sid_str, iso_value in expires_by_service.items(): + try: + sid = int(sid_str) + except (TypeError, ValueError): + continue + row = rows_by_service.get(sid) + if row is None: + continue + row.expires_at = dt.datetime.fromisoformat(iso_value) if iso_value else None + + if removed_ids: + # Revoking a pilot immediately frees any slot reserved for this user + # on it, instead of waiting for the next cleanup_loop sweep. + for slot in db.scalars( + select(RdpSlot).where( + RdpSlot.assigned_user_id == user_id, + RdpSlot.service_id.in_(removed_ids), + ) + ).all(): + slot.assigned_user_id = None db.commit() return {"ok": True} diff --git a/app/maintenance.py b/app/maintenance.py index 910f9ba..a13c166 100644 --- a/app/maintenance.py +++ b/app/maintenance.py @@ -10,7 +10,7 @@ from sqlalchemy import select from config import ENABLE_STARTUP_MAINTENANCE, SESSION_IDLE_SECONDS, WEB_POOL_SIZE from database import Base, SessionLocal, engine -from models import RdpSlot, Service, ServiceType, SessionModel, SessionStatus, User +from models import RdpSlot, Service, ServiceType, SessionModel, SessionStatus, User, UserServiceAccess from utils import ensure_icons_dir, now_utc from auth import hash_password from runtime import ( @@ -76,6 +76,30 @@ def cleanup_loop(): db.commit() for slot_id in rdp_slots_to_restart: threading.Thread(target=disconnect_rdp_slot, args=(slot_id,), daemon=True).start() + + # Pilots: a slot assigned to a user whose grant for that pilot + # service has since been revoked or has expired (per-grant + # UserServiceAccess.expires_at, or the row is just gone) goes + # back into the pool so an admin can hand it to someone else. + assigned_slots = db.scalars( + select(RdpSlot).where(RdpSlot.assigned_user_id.is_not(None)) + ).all() + if assigned_slots: + now = now_utc() + freed = 0 + for slot in assigned_slots: + access = db.scalar( + select(UserServiceAccess).where( + UserServiceAccess.user_id == slot.assigned_user_id, + UserServiceAccess.service_id == slot.service_id, + ) + ) + if access is None or (access.expires_at is not None and access.expires_at <= now): + slot.assigned_user_id = None + freed += 1 + if freed: + db.commit() + logger.info("pilot_slots_released count=%s", freed) except Exception: db.rollback() logger.exception("cleanup_loop_failed") diff --git a/app/models.py b/app/models.py index 0e8615d..70832fa 100644 --- a/app/models.py +++ b/app/models.py @@ -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): diff --git a/app/runtime.py b/app/runtime.py index 0b96168..2232b32 100644 --- a/app/runtime.py +++ b/app/runtime.py @@ -776,6 +776,13 @@ def ensure_schema_compatibility() -> None: conn.execute(text("ALTER TABLE pending_access_requests ADD COLUMN IF NOT EXISTS portal_url VARCHAR(256) NOT NULL DEFAULT ''")) conn.execute(text("ALTER TABLE pending_access_requests ADD COLUMN IF NOT EXISTS telegram_notified BOOLEAN NOT NULL DEFAULT false")) conn.execute(text("ALTER TABLE pending_access_requests ADD COLUMN IF NOT EXISTS telegram_message TEXT NOT NULL DEFAULT ''")) + # Pilots: invite-only products with per-user RDP slot assignment + # and a per-grant access window (see models.py for the full story). + conn.execute(text("ALTER TABLE services ADD COLUMN IF NOT EXISTS is_pilot BOOLEAN NOT NULL DEFAULT false")) + conn.execute(text("CREATE INDEX IF NOT EXISTS ix_services_is_pilot ON services (is_pilot)")) + conn.execute(text("ALTER TABLE rdp_slots ADD COLUMN IF NOT EXISTS assigned_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL")) + conn.execute(text("CREATE INDEX IF NOT EXISTS ix_rdp_slots_assigned_user_id ON rdp_slots (assigned_user_id)")) + conn.execute(text("ALTER TABLE user_service_access ADD COLUMN IF NOT EXISTS expires_at TIMESTAMPTZ")) conn.execute( text( """ diff --git a/app/templates/admin.html b/app/templates/admin.html index f086577..447f778 100644 --- a/app/templates/admin.html +++ b/app/templates/admin.html @@ -32,6 +32,11 @@ --av-good:#1f9d63; --av-warn:#b5680a; --av-bad:#d3453f; } + .pilot-badge{ + display:inline-block;font:700 10px/1 "Ubuntu Mono",monospace;letter-spacing:.04em; + color:#fff;background:var(--av-accent);border-radius:4px;padding:2px 5px;vertical-align:middle;margin-left:4px; + } + body.admin-page-v2{ background:var(--av-bg) !important; color:var(--av-fg); font-family:"Ubuntu","IBM Plex Sans",system-ui,sans-serif; @@ -259,7 +264,13 @@
ACL выбранного пользователя
{% for s in services %} - + {% endfor %}
@@ -466,10 +477,10 @@
{% for s in rdp_services %} -