Files
microfish/backend/app/api/admin.py
Kunthawat Greethong 8b84378fe1 feat: SaaS foundation for CrowdSight
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.
2026-08-31 13:05:21 +07:00

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()})