"""Tenant-scoped user-management endpoints.""" from __future__ import annotations from flask import Blueprint, g, jsonify, request from sqlalchemy.exc import IntegrityError from ..security.auth import current_actor, require_roles from ..security.policy import ( AuthorizationError, Role, assert_can_manage_llm_settings, assert_can_manage_user, ) from ..services.identity import IdentityRepository from ..utils.api_errors import ApiError from ..utils.locale import t admin_bp = Blueprint("admin", __name__) @admin_bp.errorhandler(ApiError) def handle_admin_error(error: ApiError): return jsonify(error.to_payload(t)), error.status_code def _organization_for_request(repo: IdentityRepository, *, payload: dict): context = g.auth_context actor = current_actor() if actor.role is not Role.SUPER_ADMIN: return context.organization organization_id = payload.get("organization_id") or request.args.get("organization_id") organization_slug = payload.get("organization_slug") or request.args.get("organization_slug") organization = ( repo.get_organization(organization_id) if isinstance(organization_id, str) and organization_id else repo.get_organization_by_slug(organization_slug) if isinstance(organization_slug, str) and organization_slug else None ) if organization is None: raise ApiError("organization_required", 400, "api.organizationRequired") return organization def _serialize_user(user, membership): return { "id": user.id, "email": user.email_normalized, "status": user.status, "locale": user.locale, "role": membership.role.value, "created_at": user.created_at.isoformat() if user.created_at else None, } @admin_bp.get("/users") @require_roles(Role.ADMIN, Role.SUPER_ADMIN) def list_users(): repo = IdentityRepository(g.db_session) organization = _organization_for_request(repo, payload={}) rows = repo.list_users_with_memberships(organization.id) users = [_serialize_user(user, membership) for user, membership in rows] return jsonify({"success": True, "data": {"users": users, "count": len(users)}}) @admin_bp.post("/users") @require_roles(Role.ADMIN, Role.SUPER_ADMIN) def create_user(): payload = request.get_json(silent=True) or {} if not isinstance(payload, dict): raise ApiError("invalid_request", 400, "api.requestError") email = payload.get("email") if not isinstance(email, str) or not email.strip(): raise ApiError("invalid_email", 400, "api.invalidEmail") requested_role = payload.get("role", Role.USER.value) try: role = requested_role if isinstance(requested_role, Role) else Role(requested_role) except (TypeError, ValueError) as exc: raise ApiError("invalid_role", 400, "api.invalidRole") from exc repo = IdentityRepository(g.db_session) organization = _organization_for_request(repo, payload=payload) actor = current_actor() try: assert_can_manage_user( actor, target_organization_id=organization.id, target_role=role, platform_scope=actor.role is Role.SUPER_ADMIN, ) except AuthorizationError as exc: raise ApiError(exc.code, 403, "common.error") from exc try: user = repo.create_user(email=email) membership = repo.create_membership(user.id, organization.id, role) except IntegrityError as exc: raise ApiError("user_exists", 409, "api.userExists") from exc except ValueError as exc: code = str(exc) if code == "invalid_email": raise ApiError("invalid_email", 400, "api.invalidEmail") from exc raise ApiError("user_creation_failed", 400, "api.requestError") from exc return jsonify({ "success": True, "data": _serialize_user(user, membership), }), 201 @admin_bp.patch("/users/") @require_roles(Role.ADMIN, Role.SUPER_ADMIN) def update_user(user_id: str): """Update a user's membership role (and optionally status) in the org.""" payload = request.get_json(silent=True) or {} if not isinstance(payload, dict): raise ApiError("invalid_request", 400, "api.requestError") repo = IdentityRepository(g.db_session) organization = _organization_for_request(repo, payload=payload) actor = current_actor() target_user = None target_membership = None for user, membership in repo.list_users_with_memberships(organization.id): if user.id == user_id: target_user = user target_membership = membership break if target_user is None or target_membership is None: raise ApiError("user_not_found", 404, "api.userNotFound") # Role change (if provided). if "role" in payload: requested_role = payload["role"] try: new_role = requested_role if isinstance(requested_role, Role) else Role(requested_role) except (TypeError, ValueError) as exc: raise ApiError("invalid_role", 400, "api.invalidRole") from exc try: assert_can_manage_user( actor, target_organization_id=organization.id, target_role=new_role, platform_scope=actor.role is Role.SUPER_ADMIN, ) except AuthorizationError as exc: raise ApiError(exc.code, 403, "common.error") from exc target_membership.role = new_role if isinstance(new_role, Role) else Role(new_role) # Status change (if provided); only super_admin may do so (safety). if "status" in payload: status = payload["status"] if status not in ("active", "disabled"): raise ApiError("invalid_status", 400, "api.invalidRequest") if actor.role is not Role.SUPER_ADMIN: raise ApiError("admin_status_change_forbidden", 403, "common.error") target_user.status = status g.db_session.commit() return jsonify({ "success": True, "data": _serialize_user(target_user, target_membership), }) def _require_platform_settings_access(): actor = current_actor() try: assert_can_manage_llm_settings(actor, platform_scope=True) except AuthorizationError as exc: raise ApiError(exc.code, 403, "common.error") from exc return actor @admin_bp.get("/settings") @require_roles(Role.SUPER_ADMIN) def get_settings(): """Return the active (masked) platform LLM settings for a super admin.""" from ..services.settings_service import SettingsService _require_platform_settings_access() svc = SettingsService(g.db_session) return jsonify({"success": True, "data": svc.active_settings()}) @admin_bp.put("/settings") @require_roles(Role.SUPER_ADMIN) def update_settings(): """Persist a new version of platform LLM settings (super admin only).""" from ..services.settings_service import SettingsService actor = _require_platform_settings_access() payload = request.get_json(silent=True) or {} if not isinstance(payload, dict): raise ApiError("invalid_request", 400, "api.requestError") settings_fields = { "provider": payload.get("provider"), "model": payload.get("model"), "base_url": payload.get("base_url"), } settings = {k: v for k, v in settings_fields.items() if isinstance(v, str) and v} if payload.get("api_key"): api_key = payload["api_key"] if not isinstance(api_key, str) or not api_key.strip(): raise ApiError("invalid_api_key", 400, "api.invalidRequest") else: api_key = None svc = SettingsService(g.db_session) version = svc.save_settings( settings, api_key=api_key, updated_by=actor.user_id, ) g.db_session.commit() return jsonify({"success": True, "data": svc.active_settings()})