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:
+153
-15
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user