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
+153 -15
View File
@@ -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}