Elevate MiroFish/CrowdSight from single-container dev to a SaaS foundation: - Local memory backend (Zep-compatible): memory services/models, local graph builder + updater, AgentActivity seam, import-boundary isolation; Zep stays default, local is opt-in behind MEMORY_BACKEND. Semantic parity not yet proven. - Durable product persistence: projects/simulations/reports schema (migration 0007) + tenant/owner-scoped ProductRepository + dual-write + scoped_project read-first + ArtifactStore abstraction; durable JobQueue + worker.py. - SaaS hardening: durable RateLimiter (wired to login), UsageService (LLM accounting), redacted AuditService, idempotency, CORS allowlist, safe API errors, single-use PasswordResetService + endpoints (covers invite-pending). - Exactly 3 roles (super_admin/admin/user) with tenant authz policy. - Admin UI: GET/POST/PATCH /api/admin/users + GET/PUT /api/admin/settings (super-admin only, encrypted/masked); AdminView.vue + SettingsView.vue with admin/super-admin route guards, th/en i18n. - Production deploy topology: multi-stage Dockerfile (frontend build + gunicorn wsgi + nginx SPA-proxy + supervisord worker), backend/wsgi.py, gunicorn dep. Backend 197 passed; frontend 10 tests + build green. ruff unavailable (gap). No commit of credentials; secrets handled via env/.env.example. Deferred: Zep semantic A/B parity, object storage cutover, mobile QA, EasyPanel container build of deploy topology.
223 lines
7.8 KiB
Python
223 lines
7.8 KiB
Python
"""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/<user_id>")
|
|
@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()})
|