Files
sales-trainer/backend/app/api/admin_routes.py
Macky 3c22d88bcd feat: demo SaaS + training flow security hardening (8/8 review gate passed)
- Demo accounts: super_admin-only provisioning into isolated DEMO_ORG_ID tenant,
  30-day UTC trial on first login, revocable, one-time credential delivery via
  optional SES/webhook (never persisted). Adds boto3 dependency.
- Analytics/report/export/privacy: shared bounded scan budget across users/groups/
  sessions, tenant-consistent session/user/group joins, scalar-only CSV export
  (no nested persisted-value stringification).
- Ownership/tenant isolation: canonical owner-tenant predicate for list/read/chat;
  client sees is_owned only, never owner_user_id.
- Lifecycle/races: status transition validation, analyzing is an in-progress gate
  (no duplicate reanalysis), structured-ready publication, stale-variant revalidation.
- Auth/setup/consent/JWT/OAuth/config: fail-closed consent, bounded JWT lifetime,
  provider-subject atomic OAuth identity, repeated-secret rejection, strict Persona
  trait validation.
- Chat/session/privacy: pre-seller opener redaction, corrupt-session recovery,
  role-aware completed-chat dashboard routing.
- Frontend: Training→product→personas→practice flow, demo/role/demo guards,
  is_owned-based ownership display, 320×568 and 500×768 responsive E2E.
- 8 independent exact-five-key review scopes passed; backend 509, frontend 26,
  production build 1775 modules, isolated E2E 15.
2026-08-25 06:39:06 +07:00

393 lines
14 KiB
Python

"""Admin routes: user provisioning + role management (no self-registration)."""
from __future__ import annotations
from flask import Blueprint, jsonify, request
from ..auth.users import AuthError
from ..config import Config
from .helpers import (
ApiError,
current_user,
internal_error,
request_json_object,
require_auth,
require_roles,
)
admin_bp = Blueprint("admin", __name__)
PUBLIC_ORG_FIELDS = ("id", "name", "plan", "seats", "active", "created_at")
def _store():
from flask import current_app
return current_app.extensions["user_store"]
def _delivery():
from flask import current_app
return current_app.extensions["demo_delivery"]
def _public_org(org: dict | None) -> dict | None:
"""Serialize an organization through an explicit admin response allowlist."""
if not isinstance(org, dict):
return None
return {field: org[field] for field in PUBLIC_ORG_FIELDS if field in org}
def _log_audit(action: str, subject: str, *, detail: dict | None = None) -> None:
"""Append a line to the audit log (sensitive platform actions)."""
import json
import time
from ..config import Config
entry = {
"ts": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
"actor": (current_user() or {}).get("username") or (current_user() or {}).get("id"),
"action": action,
"subject": subject,
"detail": detail or {},
}
try:
log_dir = Config.DATA_DIR / "audit"
log_dir.mkdir(parents=True, exist_ok=True)
with open(log_dir / "audit.jsonl", "a", encoding="utf-8") as fh:
fh.write(json.dumps(entry, ensure_ascii=False) + "\n")
except Exception:
# Audit logging must never break the request.
pass
@admin_bp.post("/demo-accounts")
@require_auth
@require_roles("super_admin")
def create_demo_account():
"""Create a dedicated-tenant demo account and deliver its password once."""
data = request_json_object()
if set(data) != {"email"}:
raise ApiError("email is the only accepted field", 400)
try:
email = _store().validate_email(data.get("email"), allow_empty=False)
except AuthError:
raise ApiError("a valid email is required", 400)
if email is None: # defensive: required validation above
raise ApiError("a valid email is required", 400)
store = _store()
try:
if store.email_exists(email):
raise ApiError("a demo account with this email already exists", 409)
org = store.ensure_demo_org()
delivery = _delivery()
username = delivery.generate_username(store.get_user_or_none)
temporary_password = delivery.generate_password()
user = store.create_demo_user(
email=email,
username=username,
password=temporary_password,
)
except ApiError:
raise
except AuthError as exc:
if "email" in str(exc).lower() and "exist" in str(exc).lower():
raise ApiError("a demo account with this email already exists", 409)
raise internal_error("demo account could not be created", exc, 400)
except Exception as exc:
raise internal_error("demo account could not be created", exc, 500)
# Delivery is deliberately best-effort. The account remains usable through
# the one-time API response even when SES/webhook configuration is absent.
try:
delivery_status = delivery.deliver(
email=email,
username=user["username"],
temporary_password=temporary_password,
trial_expires_at=user.get("trial_expires_at"),
)
except Exception as exc:
from flask import current_app
current_app.logger.error(
"demo credential delivery failed (error_type=%s)", type(exc).__name__
)
delivery_status = {
"event_id": None,
"email": {"status": "failed", "reason": "delivery_error", "attempts": 0},
"webhook": {"status": "failed", "reason": "delivery_error", "attempts": 0},
}
_log_audit(
"demo_account.create",
user["id"],
detail={
"email": email,
"org_id": org["id"],
"delivery": delivery_status,
},
)
return jsonify({
"user": store.public_user(user),
"username": user["username"],
"temporary_password": temporary_password,
"delivery": delivery_status,
"warning": "The temporary password is shown once and cannot be retrieved later.",
}), 201
@admin_bp.post("/demo-accounts/<username>/revoke")
@admin_bp.delete("/demo-accounts/<username>")
@require_auth
@require_roles("super_admin")
def revoke_demo_account(username: str):
"""Deactivate a demo account and revoke all tokens through auth_version."""
store = _store()
try:
target = store.get_user_or_none(username)
except AuthError:
raise ApiError("demo account not found", 404)
if not target or target.get("role") != "demo":
raise ApiError("demo account not found", 404)
try:
updated = store.revoke_demo(target["username"])
except AuthError:
raise ApiError("demo account not found", 404)
_log_audit("demo_account.revoke", updated["id"], detail={"org_id": updated.get("org_id")})
return jsonify({"user": store.public_user(updated), "revoked": True})
@admin_bp.get("/orgs")
@require_auth
@require_roles("admin")
def list_orgs():
"""Platform view: all organizations (super_admin). Admin sees only their own."""
actor = current_user()
if actor.get("role") == "super_admin":
orgs = [_public_org(org) for org in _store().orgs.all()]
return jsonify({"orgs": orgs})
# plain admin: only their own org
org = _store().orgs.get_or_none(actor.get("org_id"))
public_org = _public_org(org)
return jsonify({"orgs": [public_org] if public_org else []})
@admin_bp.patch("/orgs/<org_id>")
@require_auth
@require_roles("super_admin")
def update_org(org_id: str):
"""Platform: set org plan / seats / active. super_admin only."""
data = request_json_object()
fields = {}
if "plan" in data:
if not isinstance(data["plan"], str):
raise ApiError("plan must be a string", 400)
plan = data["plan"].strip()
if plan not in ("trial", "paid", "enterprise"):
raise ApiError("invalid plan (trial/paid/enterprise)")
fields["plan"] = plan
if "seats" in data:
seats_raw = data["seats"]
if isinstance(seats_raw, bool) or not isinstance(seats_raw, int):
raise ApiError("seats must be an integer", 400)
seats = seats_raw
if seats < 1:
raise ApiError("seats must be >= 1")
fields["seats"] = seats
if "active" in data:
if not isinstance(data["active"], bool):
raise ApiError("active must be a boolean")
fields["active"] = data["active"]
org_store = _store().orgs
with org_store.record_lock(org_id):
org = org_store.get_or_none(org_id)
if not org:
raise ApiError("org not found", 404)
if fields:
org_store.update(org_id, **fields)
updated = org_store.get(org_id)
_log_audit("org.update", org_id, detail=fields)
return jsonify({"org": _public_org(updated)})
@admin_bp.post("/users")
@require_auth
@require_roles("admin")
def create_user():
"""Create a user + provision a password (invite). Admin or super-admin only."""
data = request.get_json(silent=True) or {}
if not isinstance(data, dict):
raise ApiError("invalid request", 400)
name_raw = data.get("name")
if name_raw is not None and not isinstance(name_raw, str):
raise ApiError("name must be a string", 400)
name = (name_raw or "").strip()
username_raw = data.get("username")
if username_raw is None:
username_raw = data.get("email")
if username_raw is not None and not isinstance(username_raw, str):
raise ApiError("username must be a string", 400)
username = (username_raw or "").strip().lower()
password = data.get("password")
role_raw = data.get("role", "user")
if role_raw is None:
role_raw = "user"
if not isinstance(role_raw, str):
raise ApiError("role must be a string", 400)
role = role_raw.strip()
actor = current_user()
actor_role = actor.get("role")
actor_org_id = actor.get("org_id")
requested_org_id_raw = data.get("org_id")
if requested_org_id_raw is not None and not isinstance(requested_org_id_raw, str):
raise ApiError("org_id must be a string", 400)
requested_org_id = (requested_org_id_raw or "").strip()
if actor_role == "super_admin":
org_id = requested_org_id or actor_org_id
else:
if requested_org_id and requested_org_id != actor_org_id:
raise ApiError("permission denied", 403)
org_id = actor_org_id
if not isinstance(org_id, str) or not org_id:
raise ApiError("permission denied", 403)
if not username or password is None or password == "":
raise ApiError("username and password are required")
try:
_store().validate_password(password)
except AuthError as exc:
raise internal_error("user could not be created", exc, 400)
try:
normalized_email = _store().validate_email(data.get("email"))
except AuthError as exc:
raise internal_error("user could not be created", exc, 400)
if role not in Config.ROLES:
raise ApiError(f"invalid role: {role}")
if role == "demo":
raise ApiError("use the super_admin demo account endpoint", 403)
# Super-admin accounts are bootstrap/platform identities. A super_admin may
# provision another super_admin (trust-based promotion); any other actor may
# not. Admin grants are super_admin-only (kept below).
if role == "super_admin" and actor_role != "super_admin":
raise ApiError("only super_admin can grant super_admin roles", 403)
if role == "admin" and actor_role != "super_admin":
raise ApiError("only super_admin can grant admin roles", 403)
# Multi-tenant: provisioning a brand-new org (super_admin only). The created user
# becomes that org's first admin (tenant owner). Everything is isolated per org.
if "new_org" in data and not isinstance(data["new_org"], bool):
raise ApiError("new_org must be a boolean")
new_org = data.get("new_org") is True
if new_org:
if actor_role != "super_admin":
raise ApiError("only super_admin can create a new organization", 403)
if role != "admin":
raise ApiError("new-org first user must be role=admin", 400)
org = _store().create_org(name or username)
org_id = org["id"]
try:
user = _store().create_user(
org_id=org_id,
username=username,
password=password,
name=name,
role=role,
email=normalized_email,
must_setup=True,
accepted_terms=False,
)
except AuthError as exc:
if new_org:
_store().orgs.delete(org_id)
raise internal_error("user could not be created", exc, 400)
except Exception as exc:
if new_org:
_store().orgs.delete(org_id)
raise internal_error("user could not be created", exc, 500)
if new_org:
_log_audit("org.create", org_id, detail={"name": name or username})
return jsonify({"user": _store().public_user(user), "org_id": org_id}), 201
@admin_bp.get("/users")
@require_auth
@require_roles("admin")
def list_users():
actor = current_user()
if actor.get("role") == "super_admin":
users = _store().list_users()
else:
# A regular tenant admin must NOT see super_admin accounts (invisible
# platform identities). Filter them from the org scoped list.
users = [
u for u in _store().list_users(org_id=actor.get("org_id"))
if u.get("role") != "super_admin"
]
return jsonify({"users": users})
@admin_bp.put("/users/<username>")
@require_auth
@require_roles("admin")
def update_user(username: str):
data = request.get_json(silent=True) or {}
if not isinstance(data, dict):
raise ApiError("invalid request", 400)
username = username.strip().lower()
actor = current_user()
target = _store().get_user_or_none(username)
if not target:
raise ApiError("user not found", 404)
if actor.get("role") != "super_admin" and (
target.get("org_id") != actor.get("org_id")
or target.get("role") == "super_admin"
):
raise ApiError("user not found", 404)
if target.get("role") == "demo":
raise ApiError("manage demo accounts through the dedicated demo endpoint", 403)
# Only a super_admin may manage super_admin accounts (promotion or edits).
# A regular admin already can't reach here for a super_admin target (404 above).
if target.get("role") == "super_admin" and actor.get("role") != "super_admin":
raise ApiError("super_admin accounts cannot be changed through admin API", 403)
# Role changes / admin-modification restricted to super_admin. Only super_admin
# may promote to (or demote from) admin/super_admin status; a regular admin
# may only ever manage plain users.
updates: dict[str, object] = {}
role_change_requested = "role" in data
if role_change_requested:
role_raw = data.get("role")
if not isinstance(role_raw, str):
raise ApiError("role must be a string", 400)
role = role_raw.strip()
if role not in Config.ROLES:
raise ApiError(f"invalid role: {role}")
if actor.get("role") != "super_admin":
raise ApiError("permission denied", 403)
if role in ("super_admin", "admin") and actor.get("role") != "super_admin":
raise ApiError("only super_admin can grant admin/super_admin roles", 403)
updates["role"] = role
if "active" in data:
if actor.get("role") != "super_admin":
raise ApiError("permission denied", 403)
if not isinstance(data["active"], bool):
raise ApiError("active must be a boolean")
updates["active"] = data["active"]
if "password" in data:
updates["password"] = data.get("password")
if "email" in data:
updates["email"] = data.get("email")
try:
if updates:
_store().update_user_fields(username, **updates)
except AuthError as exc:
raise internal_error("profile update failed", exc, 400)
return jsonify({"user": _store().public_user(_store().get_user(username))})