Build 2602261717: master invite policy and self-service invite controls
This commit is contained in:
@@ -19,6 +19,7 @@ from ..db import (
|
||||
get_all_users,
|
||||
get_cached_requests,
|
||||
get_cached_requests_count,
|
||||
get_setting,
|
||||
get_request_cache_overview,
|
||||
get_request_cache_missing_titles,
|
||||
get_request_cache_stats,
|
||||
@@ -34,9 +35,12 @@ from ..db import (
|
||||
delete_user_activity_by_username,
|
||||
set_user_auto_search_enabled,
|
||||
set_auto_search_enabled_for_non_admin_users,
|
||||
set_user_invite_management_enabled,
|
||||
set_invite_management_enabled_for_non_admin_users,
|
||||
set_user_profile_id,
|
||||
set_user_expires_at,
|
||||
set_user_password,
|
||||
set_jellyfin_auth_cache,
|
||||
set_user_role,
|
||||
run_integrity_check,
|
||||
vacuum_db,
|
||||
@@ -83,6 +87,7 @@ from ..routers.branding import save_branding_image
|
||||
router = APIRouter(prefix="/admin", tags=["admin"], dependencies=[Depends(require_admin)])
|
||||
events_router = APIRouter(prefix="/admin/events", tags=["admin"])
|
||||
logger = logging.getLogger(__name__)
|
||||
SELF_SERVICE_INVITE_MASTER_ID_KEY = "self_service_invite_master_id"
|
||||
|
||||
SENSITIVE_KEYS = {
|
||||
"jellyseerr_api_key",
|
||||
@@ -1107,6 +1112,24 @@ async def update_user_auto_search(username: str, payload: Dict[str, Any]) -> Dic
|
||||
return {"status": "ok", "username": username, "auto_search_enabled": enabled}
|
||||
|
||||
|
||||
@router.post("/users/{username}/invite-access")
|
||||
async def update_user_invite_access(username: str, payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
enabled = payload.get("enabled") if isinstance(payload, dict) else None
|
||||
if not isinstance(enabled, bool):
|
||||
raise HTTPException(status_code=400, detail="enabled must be true or false")
|
||||
user = get_user_by_username(username)
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
set_user_invite_management_enabled(username, enabled)
|
||||
refreshed = get_user_by_username(username)
|
||||
return {
|
||||
"status": "ok",
|
||||
"username": username,
|
||||
"invite_management_enabled": bool(refreshed.get("invite_management_enabled", enabled)) if refreshed else enabled,
|
||||
"user": refreshed,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/users/{username}/profile")
|
||||
async def update_user_profile_assignment(username: str, payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
user = get_user_by_username(username)
|
||||
@@ -1172,6 +1195,20 @@ async def update_users_auto_search_bulk(payload: Dict[str, Any]) -> Dict[str, An
|
||||
}
|
||||
|
||||
|
||||
@router.post("/users/invite-access/bulk")
|
||||
async def update_users_invite_access_bulk(payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
enabled = payload.get("enabled") if isinstance(payload, dict) else None
|
||||
if not isinstance(enabled, bool):
|
||||
raise HTTPException(status_code=400, detail="enabled must be true or false")
|
||||
updated = set_invite_management_enabled_for_non_admin_users(enabled)
|
||||
return {
|
||||
"status": "ok",
|
||||
"enabled": enabled,
|
||||
"updated": updated,
|
||||
"scope": "non-admin-users",
|
||||
}
|
||||
|
||||
|
||||
@router.post("/users/profile/bulk")
|
||||
async def update_users_profile_bulk(payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
if not isinstance(payload, dict):
|
||||
@@ -1242,12 +1279,30 @@ async def update_user_password(username: str, payload: Dict[str, Any]) -> Dict[s
|
||||
user = get_user_by_username(username)
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
if user.get("auth_provider") != "local":
|
||||
raise HTTPException(
|
||||
status_code=400, detail="Password changes are only available for local users."
|
||||
)
|
||||
set_user_password(username, new_password.strip())
|
||||
return {"status": "ok", "username": username}
|
||||
new_password_clean = new_password.strip()
|
||||
auth_provider = str(user.get("auth_provider") or "local").lower()
|
||||
if auth_provider == "local":
|
||||
set_user_password(username, new_password_clean)
|
||||
return {"status": "ok", "username": username, "provider": "local"}
|
||||
if auth_provider == "jellyfin":
|
||||
runtime = get_runtime_settings()
|
||||
client = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key)
|
||||
if not client.configured():
|
||||
raise HTTPException(status_code=400, detail="Jellyfin not configured for password passthrough.")
|
||||
try:
|
||||
jf_user = await client.find_user_by_name(username)
|
||||
user_id = client._extract_user_id(jf_user)
|
||||
if not user_id:
|
||||
raise RuntimeError("Jellyfin user ID not found")
|
||||
await client.set_user_password(user_id, new_password_clean)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=502, detail=f"Jellyfin password update failed: {exc}") from exc
|
||||
set_jellyfin_auth_cache(username, new_password_clean)
|
||||
return {"status": "ok", "username": username, "provider": "jellyfin"}
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Password changes are not available for this sign-in provider.",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/profiles")
|
||||
@@ -1384,6 +1439,63 @@ async def get_invites() -> Dict[str, Any]:
|
||||
return {"invites": results}
|
||||
|
||||
|
||||
@router.get("/invites/policy")
|
||||
async def get_invite_policy() -> Dict[str, Any]:
|
||||
users = get_all_users()
|
||||
non_admin_users = [user for user in users if user.get("role") != "admin"]
|
||||
invite_access_enabled_count = sum(
|
||||
1 for user in non_admin_users if bool(user.get("invite_management_enabled", False))
|
||||
)
|
||||
raw_master_invite_id = get_setting(SELF_SERVICE_INVITE_MASTER_ID_KEY)
|
||||
master_invite_id: Optional[int] = None
|
||||
master_invite: Optional[Dict[str, Any]] = None
|
||||
if raw_master_invite_id not in (None, ""):
|
||||
try:
|
||||
candidate = int(str(raw_master_invite_id).strip())
|
||||
if candidate > 0:
|
||||
master_invite_id = candidate
|
||||
master_invite = get_signup_invite_by_id(candidate)
|
||||
except (TypeError, ValueError):
|
||||
master_invite_id = None
|
||||
master_invite = None
|
||||
return {
|
||||
"status": "ok",
|
||||
"policy": {
|
||||
"master_invite_id": master_invite_id if master_invite is not None else None,
|
||||
"master_invite": master_invite,
|
||||
"non_admin_users": len(non_admin_users),
|
||||
"invite_access_enabled_users": invite_access_enabled_count,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@router.post("/invites/policy")
|
||||
async def update_invite_policy(payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
if not isinstance(payload, dict):
|
||||
raise HTTPException(status_code=400, detail="Invalid payload")
|
||||
master_invite_value = payload.get("master_invite_id")
|
||||
if master_invite_value in (None, "", 0, "0"):
|
||||
set_setting(SELF_SERVICE_INVITE_MASTER_ID_KEY, None)
|
||||
return {"status": "ok", "policy": {"master_invite_id": None, "master_invite": None}}
|
||||
try:
|
||||
master_invite_id = int(master_invite_value)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise HTTPException(status_code=400, detail="master_invite_id must be a number") from exc
|
||||
if master_invite_id <= 0:
|
||||
raise HTTPException(status_code=400, detail="master_invite_id must be a positive number")
|
||||
invite = get_signup_invite_by_id(master_invite_id)
|
||||
if not invite:
|
||||
raise HTTPException(status_code=404, detail="Master invite not found")
|
||||
set_setting(SELF_SERVICE_INVITE_MASTER_ID_KEY, str(master_invite_id))
|
||||
return {
|
||||
"status": "ok",
|
||||
"policy": {
|
||||
"master_invite_id": master_invite_id,
|
||||
"master_invite": invite,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@router.get("/invites/trace")
|
||||
async def get_invite_trace() -> Dict[str, Any]:
|
||||
return {"status": "ok", "trace": _build_invite_trace_payload()}
|
||||
|
||||
@@ -28,6 +28,7 @@ from ..db import (
|
||||
get_user_request_stats,
|
||||
get_global_request_leader,
|
||||
get_global_request_total,
|
||||
get_setting,
|
||||
)
|
||||
from ..runtime import get_runtime_settings
|
||||
from ..clients.jellyfin import JellyfinClient
|
||||
@@ -42,6 +43,7 @@ from ..services.user_cache import (
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
SELF_SERVICE_INVITE_MASTER_ID_KEY = "self_service_invite_master_id"
|
||||
|
||||
|
||||
def _normalize_username(value: str) -> str:
|
||||
@@ -275,6 +277,89 @@ def _get_owned_invite(invite_id: int, current_user: dict) -> dict:
|
||||
return invite
|
||||
|
||||
|
||||
def _self_service_invite_access_enabled(current_user: dict) -> bool:
|
||||
if str(current_user.get("role") or "").lower() == "admin":
|
||||
return True
|
||||
return bool(current_user.get("invite_management_enabled", False))
|
||||
|
||||
|
||||
def _require_self_service_invite_access(current_user: dict) -> None:
|
||||
if _self_service_invite_access_enabled(current_user):
|
||||
return
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Invite management is not enabled for your account.",
|
||||
)
|
||||
|
||||
|
||||
def _get_self_service_master_invite() -> dict | None:
|
||||
raw_value = get_setting(SELF_SERVICE_INVITE_MASTER_ID_KEY)
|
||||
if raw_value is None:
|
||||
return None
|
||||
candidate = str(raw_value).strip()
|
||||
if not candidate:
|
||||
return None
|
||||
try:
|
||||
invite_id = int(candidate)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if invite_id <= 0:
|
||||
return None
|
||||
return get_signup_invite_by_id(invite_id)
|
||||
|
||||
|
||||
def _serialize_self_service_master_invite(invite: dict | None) -> dict | None:
|
||||
if not isinstance(invite, dict):
|
||||
return None
|
||||
profile = None
|
||||
profile_id = invite.get("profile_id")
|
||||
if isinstance(profile_id, int):
|
||||
profile = get_user_profile(profile_id)
|
||||
return {
|
||||
"id": invite.get("id"),
|
||||
"code": invite.get("code"),
|
||||
"label": invite.get("label"),
|
||||
"description": invite.get("description"),
|
||||
"profile_id": invite.get("profile_id"),
|
||||
"profile": (
|
||||
{"id": profile.get("id"), "name": profile.get("name")}
|
||||
if isinstance(profile, dict)
|
||||
else None
|
||||
),
|
||||
"role": invite.get("role"),
|
||||
"max_uses": invite.get("max_uses"),
|
||||
"enabled": bool(invite.get("enabled")),
|
||||
"expires_at": invite.get("expires_at"),
|
||||
"is_expired": bool(invite.get("is_expired")),
|
||||
"is_usable": bool(invite.get("is_usable")),
|
||||
"created_at": invite.get("created_at"),
|
||||
"updated_at": invite.get("updated_at"),
|
||||
}
|
||||
|
||||
|
||||
def _master_invite_controlled_values(master_invite: dict) -> tuple[int | None, str, int | None, bool, str | None]:
|
||||
profile_id_raw = master_invite.get("profile_id")
|
||||
profile_id: int | None = None
|
||||
if isinstance(profile_id_raw, int):
|
||||
profile_id = profile_id_raw
|
||||
elif profile_id_raw not in (None, ""):
|
||||
try:
|
||||
profile_id = int(profile_id_raw)
|
||||
except (TypeError, ValueError):
|
||||
profile_id = None
|
||||
role_value = str(master_invite.get("role") or "").strip().lower()
|
||||
role = role_value if role_value in {"user", "admin"} else "user"
|
||||
max_uses_raw = master_invite.get("max_uses")
|
||||
try:
|
||||
max_uses = int(max_uses_raw) if max_uses_raw is not None else None
|
||||
except (TypeError, ValueError):
|
||||
max_uses = None
|
||||
enabled = bool(master_invite.get("enabled", True))
|
||||
expires_at_value = master_invite.get("expires_at")
|
||||
expires_at = str(expires_at_value).strip() if isinstance(expires_at_value, str) and str(expires_at_value).strip() else None
|
||||
return profile_id, role, max_uses, enabled, expires_at
|
||||
|
||||
|
||||
@router.post("/login")
|
||||
async def login(form_data: OAuth2PasswordRequestForm = Depends()) -> dict:
|
||||
user = verify_user_password(form_data.username, form_data.password)
|
||||
@@ -568,14 +653,25 @@ async def profile_invites(current_user: dict = Depends(get_current_user)) -> dic
|
||||
username = str(current_user.get("username") or "").strip()
|
||||
if not username:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid user")
|
||||
master_invite = _get_self_service_master_invite()
|
||||
invite_access_enabled = _self_service_invite_access_enabled(current_user)
|
||||
invites = [_serialize_self_invite(invite) for invite in _current_user_invites(username)]
|
||||
return {"invites": invites, "count": len(invites)}
|
||||
return {
|
||||
"invites": invites,
|
||||
"count": len(invites),
|
||||
"invite_access": {
|
||||
"enabled": invite_access_enabled,
|
||||
"managed_by_master": bool(master_invite),
|
||||
},
|
||||
"master_invite": _serialize_self_service_master_invite(master_invite),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/profile/invites")
|
||||
async def create_profile_invite(payload: dict, current_user: dict = Depends(get_current_user)) -> dict:
|
||||
if not isinstance(payload, dict):
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid payload")
|
||||
_require_self_service_invite_access(current_user)
|
||||
username = str(current_user.get("username") or "").strip()
|
||||
if not username:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid user")
|
||||
@@ -603,20 +699,32 @@ async def create_profile_invite(payload: dict, current_user: dict = Depends(get_
|
||||
if description is not None:
|
||||
description = str(description).strip() or None
|
||||
|
||||
max_uses = _parse_optional_positive_int(payload.get("max_uses"), "max_uses")
|
||||
expires_at = _parse_optional_expires_at(payload.get("expires_at"))
|
||||
enabled = bool(payload.get("enabled", True))
|
||||
|
||||
profile_id = current_user.get("profile_id")
|
||||
if not isinstance(profile_id, int) or profile_id <= 0:
|
||||
profile_id = None
|
||||
master_invite = _get_self_service_master_invite()
|
||||
if master_invite:
|
||||
if not bool(master_invite.get("enabled")) or bool(master_invite.get("is_expired")) or master_invite.get("is_usable") is False:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Self-service invites are temporarily unavailable (master invite template is disabled or expired).",
|
||||
)
|
||||
profile_id, _master_role, max_uses, enabled, expires_at = _master_invite_controlled_values(master_invite)
|
||||
if profile_id is not None and not get_user_profile(profile_id):
|
||||
profile_id = None
|
||||
role = "user"
|
||||
else:
|
||||
max_uses = _parse_optional_positive_int(payload.get("max_uses"), "max_uses")
|
||||
expires_at = _parse_optional_expires_at(payload.get("expires_at"))
|
||||
enabled = bool(payload.get("enabled", True))
|
||||
profile_id = current_user.get("profile_id")
|
||||
if not isinstance(profile_id, int) or profile_id <= 0:
|
||||
profile_id = None
|
||||
role = "user"
|
||||
|
||||
invite = create_signup_invite(
|
||||
code=code,
|
||||
label=label,
|
||||
description=description,
|
||||
profile_id=profile_id,
|
||||
role="user",
|
||||
role=role,
|
||||
max_uses=max_uses,
|
||||
enabled=enabled,
|
||||
expires_at=expires_at,
|
||||
@@ -631,6 +739,7 @@ async def update_profile_invite(
|
||||
) -> dict:
|
||||
if not isinstance(payload, dict):
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid payload")
|
||||
_require_self_service_invite_access(current_user)
|
||||
existing = _get_owned_invite(invite_id, current_user)
|
||||
|
||||
requested_code = payload.get("code", existing.get("code"))
|
||||
@@ -651,18 +760,27 @@ async def update_profile_invite(
|
||||
if description is not None:
|
||||
description = str(description).strip() or None
|
||||
|
||||
max_uses = _parse_optional_positive_int(payload.get("max_uses", existing.get("max_uses")), "max_uses")
|
||||
expires_at = _parse_optional_expires_at(payload.get("expires_at", existing.get("expires_at")))
|
||||
enabled_raw = payload.get("enabled", existing.get("enabled"))
|
||||
enabled = bool(enabled_raw)
|
||||
master_invite = _get_self_service_master_invite()
|
||||
if master_invite:
|
||||
profile_id, _master_role, max_uses, enabled, expires_at = _master_invite_controlled_values(master_invite)
|
||||
if profile_id is not None and not get_user_profile(profile_id):
|
||||
profile_id = None
|
||||
role = "user"
|
||||
else:
|
||||
max_uses = _parse_optional_positive_int(payload.get("max_uses", existing.get("max_uses")), "max_uses")
|
||||
expires_at = _parse_optional_expires_at(payload.get("expires_at", existing.get("expires_at")))
|
||||
enabled_raw = payload.get("enabled", existing.get("enabled"))
|
||||
enabled = bool(enabled_raw)
|
||||
profile_id = existing.get("profile_id")
|
||||
role = existing.get("role")
|
||||
|
||||
invite = update_signup_invite(
|
||||
invite_id,
|
||||
code=code,
|
||||
label=label,
|
||||
description=description,
|
||||
profile_id=existing.get("profile_id"),
|
||||
role=existing.get("role"),
|
||||
profile_id=profile_id,
|
||||
role=role,
|
||||
max_uses=max_uses,
|
||||
enabled=enabled,
|
||||
expires_at=expires_at,
|
||||
@@ -674,6 +792,7 @@ async def update_profile_invite(
|
||||
|
||||
@router.delete("/profile/invites/{invite_id}")
|
||||
async def delete_profile_invite(invite_id: int, current_user: dict = Depends(get_current_user)) -> dict:
|
||||
_require_self_service_invite_access(current_user)
|
||||
_get_owned_invite(invite_id, current_user)
|
||||
deleted = delete_signup_invite(invite_id)
|
||||
if not deleted:
|
||||
@@ -683,11 +802,6 @@ async def delete_profile_invite(invite_id: int, current_user: dict = Depends(get
|
||||
|
||||
@router.post("/password")
|
||||
async def change_password(payload: dict, current_user: dict = Depends(get_current_user)) -> dict:
|
||||
if current_user.get("auth_provider") != "local":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Password changes are only available for local users.",
|
||||
)
|
||||
current_password = payload.get("current_password") if isinstance(payload, dict) else None
|
||||
new_password = payload.get("new_password") if isinstance(payload, dict) else None
|
||||
if not isinstance(current_password, str) or not isinstance(new_password, str):
|
||||
@@ -696,8 +810,64 @@ async def change_password(payload: dict, current_user: dict = Depends(get_curren
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST, detail="Password must be at least 8 characters."
|
||||
)
|
||||
user = verify_user_password(current_user["username"], current_password)
|
||||
if not user:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Current password is incorrect")
|
||||
set_user_password(current_user["username"], new_password.strip())
|
||||
return {"status": "ok"}
|
||||
username = str(current_user.get("username") or "").strip()
|
||||
auth_provider = str(current_user.get("auth_provider") or "local").lower()
|
||||
if not username:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid user")
|
||||
new_password_clean = new_password.strip()
|
||||
|
||||
if auth_provider == "local":
|
||||
user = verify_user_password(username, current_password)
|
||||
if not user:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Current password is incorrect")
|
||||
set_user_password(username, new_password_clean)
|
||||
return {"status": "ok", "provider": "local"}
|
||||
|
||||
if auth_provider == "jellyfin":
|
||||
runtime = get_runtime_settings()
|
||||
client = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key)
|
||||
if not client.configured():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Jellyfin is not configured for password passthrough.",
|
||||
)
|
||||
try:
|
||||
auth_result = await client.authenticate_by_name(username, current_password)
|
||||
if not isinstance(auth_result, dict) or not auth_result.get("User"):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED, detail="Current password is incorrect"
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as exc:
|
||||
detail = _extract_http_error_detail(exc)
|
||||
if isinstance(exc, httpx.HTTPStatusError) and exc.response is not None and exc.response.status_code in {401, 403}:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED, detail="Current password is incorrect"
|
||||
) from exc
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail=f"Jellyfin password validation failed: {detail}",
|
||||
) from exc
|
||||
|
||||
try:
|
||||
jf_user = await client.find_user_by_name(username)
|
||||
user_id = client._extract_user_id(jf_user)
|
||||
if not user_id:
|
||||
raise RuntimeError("Jellyfin user ID not found")
|
||||
await client.set_user_password(user_id, new_password_clean)
|
||||
except Exception as exc:
|
||||
detail = _extract_http_error_detail(exc)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail=f"Jellyfin password update failed: {detail}",
|
||||
) from exc
|
||||
|
||||
# Keep Magent's Jellyfin auth cache in sync for faster subsequent sign-ins.
|
||||
set_jellyfin_auth_cache(username, new_password_clean)
|
||||
return {"status": "ok", "provider": "jellyfin"}
|
||||
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Password changes are not available for this sign-in provider.",
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user