| """ |
| app/services/admin_store.py |
| ββββββββββββββββββββββββββββ |
| Persistence layer for multi-admin accounts with RBAC. |
| |
| Data file: data/admins.json (or /data/admins.json on HF Spaces) |
| Passwords: PBKDF2-HMAC-SHA256, 260 000 iterations, 32-byte random salt. |
| Thread safety: all reads/writes guarded by a single asyncio.Lock. |
| File safety: atomic writes via temp-file + os.replace(). |
| """ |
|
|
| import asyncio |
| import hashlib |
| import json |
| import logging |
| import os |
| import re |
| import secrets |
| import time |
| from datetime import datetime, timezone, timedelta |
| from pathlib import Path |
| from typing import Optional, Dict, List, Any, Tuple |
|
|
| from app.core.config import get_settings |
|
|
| logger = logging.getLogger(__name__) |
|
|
| |
| _HASH_ITERATIONS = 260_000 |
| _HASH_ALGO = "sha256" |
| _SALT_BYTES = 32 |
| _MAX_AUDIT_LOG = 500 |
| _LOCKOUT_MINUTES = 15 |
| _MAX_FAILED_ATTEMPTS = 5 |
| _USERNAME_RE = re.compile(r"^[a-zA-Z0-9_]{3,32}$") |
| _PASSWORD_AGE_WARNING_DAYS = 90 |
| _WHATSAPP_RE = re.compile(r"^[1-9]\d{7,14}$") |
|
|
| |
| _SUB_PERMISSION_HIERARCHY = { |
| "manage_sessions": { |
| "base": "sessions.view", |
| "children": [ |
| "sessions.view", "sessions.reply", "sessions.takeover", |
| "sessions.block", "sessions.end_chat", "sessions.archive", |
| "sessions.delete", |
| ], |
| }, |
| "view_analytics": { |
| "base": "analytics.dashboard", |
| "children": [ |
| "analytics.dashboard", "analytics.sla", "analytics.export", |
| ], |
| }, |
| "manage_whatsapp": { |
| "base": "whatsapp.view", |
| "children": [ |
| "whatsapp.view", "whatsapp.edit", "whatsapp.test", "whatsapp.qr", |
| ], |
| }, |
| "manage_admins": { |
| "base": "admins.view", |
| "children": [ |
| "admins.view", "admins.create", "admins.edit", |
| "admins.delete", "admins.reset_password", |
| ], |
| }, |
| } |
|
|
| |
| _SETTINGS_SUB_PERMISSIONS = [ |
| "settings.notifications", "settings.config", |
| "settings.security", "settings.version", |
| ] |
|
|
| |
| _TOP_LEVEL_PERMISSIONS = [ |
| "manage_sessions", "manage_whatsapp", "view_analytics", |
| "manage_admins", "manage_kb", |
| ] |
|
|
| |
| PERMISSION_TEMPLATES = { |
| "support_agent": { |
| "name": "π§ Support Agent", |
| "permissions": [ |
| "manage_sessions", "sessions.view", "sessions.reply", "sessions.takeover", |
| ], |
| }, |
| "read_only_viewer": { |
| "name": "ποΈ Read-Only Viewer", |
| "permissions": [ |
| "manage_sessions", "sessions.view", |
| "view_analytics", "analytics.dashboard", "analytics.sla", |
| ], |
| }, |
| "analytics_manager": { |
| "name": "π Analytics Manager", |
| "permissions": [ |
| "view_analytics", "analytics.dashboard", "analytics.sla", "analytics.export", |
| ], |
| }, |
| "whatsapp_manager": { |
| "name": "π± WhatsApp Manager", |
| "permissions": [ |
| "manage_whatsapp", "whatsapp.view", "whatsapp.edit", |
| "whatsapp.test", "whatsapp.qr", |
| ], |
| }, |
| "full_sub_admin": { |
| "name": "π‘οΈ Full Sub-Admin", |
| "permissions": [ |
| "manage_sessions", "sessions.view", "sessions.reply", |
| "sessions.takeover", "sessions.block", "sessions.end_chat", |
| "sessions.archive", "sessions.delete", |
| "view_analytics", "analytics.dashboard", "analytics.sla", "analytics.export", |
| "manage_whatsapp", "whatsapp.view", "whatsapp.edit", |
| "whatsapp.test", "whatsapp.qr", |
| "manage_kb", |
| "settings.notifications", "settings.config", |
| "settings.security", "settings.version", |
| ], |
| }, |
| } |
|
|
| |
| _lock = asyncio.Lock() |
|
|
|
|
| def _get_admins_path() -> Path: |
| """Returns path to admins.json (respects HF Spaces /data volume).""" |
| base = Path("/data") if Path("/data").exists() else Path("data") |
| base.mkdir(parents=True, exist_ok=True) |
| return base / "admins.json" |
|
|
|
|
| |
|
|
| def hash_password(password: str) -> Tuple[str, str]: |
| """Hash a password with PBKDF2-HMAC-SHA256. Returns (hex_hash, hex_salt).""" |
| salt = os.urandom(_SALT_BYTES) |
| dk = hashlib.pbkdf2_hmac( |
| _HASH_ALGO, |
| password.encode("utf-8"), |
| salt, |
| _HASH_ITERATIONS, |
| ) |
| return dk.hex(), salt.hex() |
|
|
|
|
| def _verify_hash(password: str, stored_hash: str, stored_salt: str) -> bool: |
| """Constant-time password verification.""" |
| salt = bytes.fromhex(stored_salt) |
| dk = hashlib.pbkdf2_hmac( |
| _HASH_ALGO, |
| password.encode("utf-8"), |
| salt, |
| _HASH_ITERATIONS, |
| ) |
| return secrets.compare_digest(dk.hex(), stored_hash) |
|
|
|
|
| |
|
|
| def _load_admins_sync() -> dict: |
| """Read admins.json. Returns empty structure if missing/corrupt.""" |
| path = _get_admins_path() |
| if not path.exists(): |
| return {"admins": [], "audit_log": [], "templates": {}} |
| try: |
| with open(path, "r", encoding="utf-8") as f: |
| data = json.load(f) |
| |
| data.setdefault("admins", []) |
| data.setdefault("audit_log", []) |
| data.setdefault("templates", {}) |
| return data |
| except Exception as e: |
| logger.error("Failed to read admins.json: %s", e) |
| return {"admins": [], "audit_log": [], "templates": {}} |
|
|
|
|
| def _save_admins_sync(data: dict) -> None: |
| """Atomic write: write to .tmp then os.replace().""" |
| path = _get_admins_path() |
| tmp_path = path.with_suffix(".tmp") |
| with open(tmp_path, "w", encoding="utf-8") as f: |
| json.dump(data, f, indent=2, default=str) |
| os.replace(str(tmp_path), str(path)) |
|
|
|
|
| |
|
|
| async def _load_admins() -> dict: |
| return await asyncio.to_thread(_load_admins_sync) |
|
|
|
|
| async def _save_admins(data: dict) -> None: |
| await asyncio.to_thread(_save_admins_sync, data) |
|
|
|
|
| |
|
|
| def _append_audit(data: dict, actor: str, action: str, target: str, detail: str) -> None: |
| """Append an audit log entry (in-place). Prune if over cap.""" |
| entry = { |
| "timestamp": datetime.now(timezone.utc).isoformat(), |
| "actor": actor, |
| "action": action, |
| "target": target, |
| "detail": detail, |
| } |
| data["audit_log"].append(entry) |
| |
| if len(data["audit_log"]) > _MAX_AUDIT_LOG: |
| data["audit_log"] = data["audit_log"][-_MAX_AUDIT_LOG:] |
|
|
|
|
| |
|
|
| async def get_admin(username: str) -> Optional[dict]: |
| """Return a single admin record (case-insensitive). None if not found.""" |
| async with _lock: |
| data = await _load_admins() |
| username_lower = username.lower() |
| for admin in data["admins"]: |
| if admin["username"].lower() == username_lower: |
| return admin |
| return None |
|
|
|
|
| async def get_all_admins() -> List[dict]: |
| """Return list of all admins with safe fields only (no hash/salt).""" |
| async with _lock: |
| data = await _load_admins() |
| safe_fields = [ |
| "username", "role", "email", "display_name", "notes", |
| "permissions", "is_active", "created_at", "created_by", |
| "last_login", "last_active", "password_changed_at", |
| "whatsapp_number", "on_duty", |
| ] |
| result = [] |
| for admin in data["admins"]: |
| safe = {k: admin.get(k) for k in safe_fields} |
| result.append(safe) |
| return result |
|
|
|
|
| async def create_admin( |
| username: str, |
| email: str, |
| password: str, |
| role: str = "sub_admin", |
| permissions: Optional[List[str]] = None, |
| display_name: str = "", |
| notes: str = "", |
| created_by: str = "system", |
| ) -> dict: |
| """Create a new admin account. Raises ValueError on validation failure.""" |
| |
| if not _USERNAME_RE.match(username): |
| raise ValueError("Username must be 3-32 alphanumeric/underscore characters.") |
|
|
| |
| if len(password) < 8: |
| raise ValueError("Password must be at least 8 characters.") |
|
|
| |
| if role not in ("superadmin", "sub_admin"): |
| raise ValueError("Role must be 'superadmin' or 'sub_admin'.") |
|
|
| |
| if role == "sub_admin": |
| if not permissions or len(permissions) == 0: |
| raise ValueError("A sub-admin must have at least one permission.") |
| |
| _validate_permissions(permissions) |
|
|
| async with _lock: |
| data = await _load_admins() |
|
|
| |
| username_lower = username.lower() |
| for admin in data["admins"]: |
| if admin["username"].lower() == username_lower: |
| raise ValueError(f"Username '{username}' already exists.") |
|
|
| |
| email_lower = email.lower() |
| for admin in data["admins"]: |
| if admin.get("email", "").lower() == email_lower: |
| raise ValueError(f"Email '{email}' is already in use.") |
|
|
| |
| pw_hash, pw_salt = hash_password(password) |
| now = datetime.now(timezone.utc).isoformat() |
|
|
| |
| if role == "superadmin": |
| perms = list(_TOP_LEVEL_PERMISSIONS) |
| else: |
| perms = list(permissions) if permissions else [] |
|
|
| record = { |
| "username": username, |
| "role": role, |
| "email": email, |
| "display_name": display_name or username, |
| "notes": notes[:500] if notes else "", |
| "password_hash": pw_hash, |
| "password_salt": pw_salt, |
| "password_changed_at": now, |
| "permissions": perms, |
| "is_active": True, |
| "created_at": now, |
| "created_by": created_by, |
| "last_login": None, |
| "last_active": None, |
| "failed_attempts": 0, |
| "locked_until": None, |
| "force_logout_at": None, |
| "whatsapp_number": "", |
| "on_duty": True, |
| } |
|
|
| data["admins"].append(record) |
| _append_audit(data, created_by, "create_admin", username, |
| f"Created {role} with permissions: {', '.join(perms)}") |
| await _save_admins(data) |
|
|
| logger.info("Admin account created: %s (role=%s, by=%s)", username, role, created_by) |
| return {k: v for k, v in record.items() if k not in ("password_hash", "password_salt")} |
|
|
|
|
| async def update_admin( |
| username: str, |
| requesting_user: str, |
| role: Optional[str] = None, |
| email: Optional[str] = None, |
| permissions: Optional[List[str]] = None, |
| display_name: Optional[str] = None, |
| notes: Optional[str] = None, |
| ) -> dict: |
| """Update an existing admin's profile. Raises ValueError on rule violations.""" |
| async with _lock: |
| data = await _load_admins() |
| admin = _find_admin(data, username) |
| if not admin: |
| raise ValueError(f"Admin '{username}' not found.") |
|
|
| changes = [] |
|
|
| |
| if role is not None and role != admin["role"]: |
| if role not in ("superadmin", "sub_admin"): |
| raise ValueError("Role must be 'superadmin' or 'sub_admin'.") |
| |
| if admin["role"] == "superadmin" and role == "sub_admin": |
| sa_count = sum(1 for a in data["admins"] if a["role"] == "superadmin" and a["is_active"]) |
| if sa_count <= 1: |
| raise ValueError("Cannot demote the last superadmin.") |
| changes.append(f"role: {admin['role']} β {role}") |
| admin["role"] = role |
| |
| if role == "superadmin": |
| admin["permissions"] = list(_TOP_LEVEL_PERMISSIONS) |
|
|
| |
| if email is not None and email.lower() != admin.get("email", "").lower(): |
| email_lower = email.lower() |
| for other in data["admins"]: |
| if other["username"].lower() != username.lower() and other.get("email", "").lower() == email_lower: |
| raise ValueError(f"Email '{email}' is already in use.") |
| changes.append(f"email: {admin.get('email', '')} β {email}") |
| admin["email"] = email |
|
|
| |
| if permissions is not None and admin["role"] == "sub_admin": |
| if len(permissions) == 0: |
| raise ValueError("A sub-admin must have at least one permission.") |
| _validate_permissions(permissions) |
| old_perms = set(admin.get("permissions", [])) |
| new_perms = set(permissions) |
| if old_perms != new_perms: |
| added = new_perms - old_perms |
| removed = old_perms - new_perms |
| detail_parts = [] |
| if added: |
| detail_parts.append(f"added: {', '.join(sorted(added))}") |
| if removed: |
| detail_parts.append(f"removed: {', '.join(sorted(removed))}") |
| changes.append(f"permissions: {'; '.join(detail_parts)}") |
| admin["permissions"] = list(permissions) |
|
|
| |
| if display_name is not None: |
| admin["display_name"] = display_name |
|
|
| |
| if notes is not None: |
| admin["notes"] = notes[:500] |
|
|
| if changes: |
| _append_audit(data, requesting_user, "update_admin", username, |
| "; ".join(changes)) |
| await _save_admins(data) |
|
|
| return {k: v for k, v in admin.items() if k not in ("password_hash", "password_salt")} |
|
|
|
|
| def _validate_whatsapp_number(number: str) -> str: |
| """Validate and normalize a WhatsApp number. |
| Accepts with or without leading +. Stores WITHOUT + to match superadmin convention.""" |
| if not number or not number.strip(): |
| return "" |
| number = number.strip() |
| |
| if number.startswith("+"): |
| number = number[1:] |
| if not _WHATSAPP_RE.match(number): |
| raise ValueError("WhatsApp number must be in international format (e.g. 923001234567).") |
| return number |
|
|
|
|
| async def update_admin_profile( |
| username: str, |
| display_name: Optional[str] = None, |
| whatsapp_number: Optional[str] = None, |
| on_duty: Optional[bool] = None, |
| ) -> dict: |
| """Self-service profile update. Any admin can update their own profile.""" |
| async with _lock: |
| data = await _load_admins() |
| admin = _find_admin(data, username) |
| if not admin: |
| raise ValueError(f"Admin '{username}' not found.") |
|
|
| changes = [] |
| if display_name is not None: |
| admin["display_name"] = display_name |
| changes.append(f"display_name β {display_name}") |
|
|
| if whatsapp_number is not None: |
| cleaned = _validate_whatsapp_number(whatsapp_number) |
| admin["whatsapp_number"] = cleaned |
| changes.append(f"whatsapp_number β {'set' if cleaned else 'cleared'}") |
|
|
| if on_duty is not None: |
| admin["on_duty"] = bool(on_duty) |
| changes.append(f"on_duty β {on_duty}") |
|
|
| if changes: |
| _append_audit(data, username, "update_profile", username, "; ".join(changes)) |
| await _save_admins(data) |
|
|
| return {k: v for k, v in admin.items() if k not in ("password_hash", "password_salt")} |
|
|
|
|
| async def delete_admin(username: str, requesting_user: str) -> bool: |
| """Delete an admin account. Raises ValueError on rule violations.""" |
| async with _lock: |
| data = await _load_admins() |
| admin = _find_admin(data, username) |
| if not admin: |
| raise ValueError(f"Admin '{username}' not found.") |
|
|
| |
| if username.lower() == requesting_user.lower(): |
| raise ValueError("Cannot delete your own account.") |
|
|
| |
| if admin["role"] == "superadmin": |
| sa_count = sum(1 for a in data["admins"] if a["role"] == "superadmin") |
| if sa_count <= 1: |
| raise ValueError("Cannot delete the last superadmin.") |
|
|
| data["admins"] = [a for a in data["admins"] if a["username"].lower() != username.lower()] |
| _append_audit(data, requesting_user, "delete_admin", username, |
| f"Deleted {admin['role']} account") |
| await _save_admins(data) |
|
|
| logger.info("Admin account deleted: %s (by=%s)", username, requesting_user) |
| return True |
|
|
|
|
| async def verify_password(username: str, password: str) -> Optional[dict]: |
| """ |
| Verify password. Returns admin record on success, None on failure. |
| Also handles .env fallback for the initial superadmin. |
| Increments failed_attempts and locks after 5. |
| """ |
| settings = get_settings() |
| async with _lock: |
| data = await _load_admins() |
| admin = _find_admin(data, username) |
|
|
| if not admin: |
| |
| if secrets.compare_digest(username.lower(), settings.admin_user.lower()): |
| if secrets.compare_digest(password, settings.admin_pass): |
| return { |
| "username": settings.admin_user, |
| "role": "superadmin", |
| "email": settings.admin_email, |
| "permissions": list(_TOP_LEVEL_PERMISSIONS), |
| "is_active": True, |
| "_env_fallback": True, |
| } |
| return None |
|
|
| |
| if not admin.get("is_active", True): |
| raise PermissionError("Your account has been suspended. Contact your superadmin.") |
|
|
| |
| locked_until = admin.get("locked_until") |
| if locked_until: |
| try: |
| lock_time = datetime.fromisoformat(locked_until) |
| if datetime.now(timezone.utc) < lock_time: |
| raise PermissionError( |
| f"Account locked due to too many failed attempts. " |
| f"Try again after {lock_time.strftime('%H:%M UTC')}." |
| ) |
| else: |
| |
| admin["locked_until"] = None |
| admin["failed_attempts"] = 0 |
| except (ValueError, TypeError): |
| pass |
|
|
| |
| verified = _verify_hash(password, admin["password_hash"], admin["password_salt"]) |
|
|
| if not verified: |
| |
| if (admin["role"] == "superadmin" |
| and admin.get("created_by") == "system" |
| and secrets.compare_digest(password, settings.admin_pass)): |
| verified = True |
|
|
| if not verified: |
| admin["failed_attempts"] = admin.get("failed_attempts", 0) + 1 |
| if admin["failed_attempts"] >= _MAX_FAILED_ATTEMPTS: |
| lock_time = datetime.now(timezone.utc) + timedelta(minutes=_LOCKOUT_MINUTES) |
| admin["locked_until"] = lock_time.isoformat() |
| logger.warning("Account locked: %s (too many failed attempts)", username) |
| await _save_admins(data) |
| return None |
|
|
| |
| admin["failed_attempts"] = 0 |
| admin["locked_until"] = None |
| await _save_admins(data) |
|
|
| return {k: v for k, v in admin.items() if k not in ("password_hash", "password_salt")} |
|
|
|
|
| async def change_password(username: str, old_password: str, new_password: str) -> bool: |
| """Change own password. Verifies old password first. Raises ValueError on failure.""" |
| if len(new_password) < 8: |
| raise ValueError("New password must be at least 8 characters.") |
|
|
| async with _lock: |
| data = await _load_admins() |
| admin = _find_admin(data, username) |
| if not admin: |
| raise ValueError("Admin not found.") |
|
|
| settings = get_settings() |
| |
| old_ok = _verify_hash(old_password, admin["password_hash"], admin["password_salt"]) |
| |
| if not old_ok and admin.get("created_by") == "system": |
| old_ok = secrets.compare_digest(old_password, settings.admin_pass) |
|
|
| if not old_ok: |
| raise ValueError("Current password is incorrect.") |
|
|
| |
| pw_hash, pw_salt = hash_password(new_password) |
| admin["password_hash"] = pw_hash |
| admin["password_salt"] = pw_salt |
| admin["password_changed_at"] = datetime.now(timezone.utc).isoformat() |
| _append_audit(data, username, "change_password", username, "Password changed by user") |
| await _save_admins(data) |
|
|
| return True |
|
|
|
|
| async def reset_password(username: str, new_password: str, requesting_user: str) -> bool: |
| """Superadmin resets another user's password. Raises ValueError on failure.""" |
| if len(new_password) < 8: |
| raise ValueError("New password must be at least 8 characters.") |
|
|
| async with _lock: |
| data = await _load_admins() |
| admin = _find_admin(data, username) |
| if not admin: |
| raise ValueError("Admin not found.") |
|
|
| pw_hash, pw_salt = hash_password(new_password) |
| admin["password_hash"] = pw_hash |
| admin["password_salt"] = pw_salt |
| admin["password_changed_at"] = datetime.now(timezone.utc).isoformat() |
| _append_audit(data, requesting_user, "reset_password", username, |
| "Password reset by superadmin") |
| await _save_admins(data) |
|
|
| return True |
|
|
|
|
| async def record_login(username: str) -> None: |
| """Update last_login timestamp and reset failed attempts.""" |
| async with _lock: |
| data = await _load_admins() |
| admin = _find_admin(data, username) |
| if admin: |
| admin["last_login"] = datetime.now(timezone.utc).isoformat() |
| admin["last_active"] = admin["last_login"] |
| admin["failed_attempts"] = 0 |
| admin["locked_until"] = None |
| await _save_admins(data) |
|
|
|
|
| async def set_active_status(username: str, is_active: bool, requesting_user: str) -> bool: |
| """Suspend or reactivate an admin account.""" |
| async with _lock: |
| data = await _load_admins() |
| admin = _find_admin(data, username) |
| if not admin: |
| raise ValueError(f"Admin '{username}' not found.") |
|
|
| |
| if username.lower() == requesting_user.lower(): |
| raise ValueError("Cannot suspend your own account.") |
|
|
| |
| if not is_active and admin["role"] == "superadmin": |
| active_sa = sum(1 for a in data["admins"] |
| if a["role"] == "superadmin" and a.get("is_active", True)) |
| if active_sa <= 1: |
| raise ValueError("Cannot suspend the last active superadmin.") |
|
|
| admin["is_active"] = is_active |
| action = "reactivate_admin" if is_active else "suspend_admin" |
| _append_audit(data, requesting_user, action, username, |
| f"Account {'reactivated' if is_active else 'suspended'}") |
| await _save_admins(data) |
|
|
| return True |
|
|
|
|
| async def force_logout(username: str, requesting_user: str) -> bool: |
| """Set force_logout_at to invalidate the user's current session.""" |
| async with _lock: |
| data = await _load_admins() |
| admin = _find_admin(data, username) |
| if not admin: |
| raise ValueError(f"Admin '{username}' not found.") |
|
|
| if username.lower() == requesting_user.lower(): |
| raise ValueError("Cannot force logout yourself.") |
|
|
| admin["force_logout_at"] = datetime.now(timezone.utc).isoformat() |
| _append_audit(data, requesting_user, "force_logout", username, |
| "Session forcefully terminated") |
| await _save_admins(data) |
|
|
| return True |
|
|
|
|
| async def check_force_logout(username: str) -> bool: |
| """Returns True if the user should be force-logged-out.""" |
| admin = await get_admin(username) |
| if not admin: |
| return False |
| force_at = admin.get("force_logout_at") |
| last_login = admin.get("last_login") |
| if not force_at: |
| return False |
| if not last_login: |
| return True |
| try: |
| force_dt = datetime.fromisoformat(force_at) |
| login_dt = datetime.fromisoformat(last_login) |
| return force_dt > login_dt |
| except (ValueError, TypeError): |
| return False |
|
|
|
|
| async def get_audit_log(limit: int = 50, offset: int = 0) -> List[dict]: |
| """Return audit log entries, newest first.""" |
| async with _lock: |
| data = await _load_admins() |
| log = data.get("audit_log", []) |
| |
| log_reversed = list(reversed(log)) |
| return log_reversed[offset:offset + limit] |
|
|
|
|
| async def get_audit_log_total() -> int: |
| """Return total count of audit log entries.""" |
| async with _lock: |
| data = await _load_admins() |
| return len(data.get("audit_log", [])) |
|
|
|
|
| async def seed_initial_admin() -> None: |
| """ |
| Called at startup. If admins.json is empty or missing, seed it with |
| the superadmin from .env (ADMIN_USER / ADMIN_PASS / ADMIN_EMAIL). |
| Safe to call on every restart. |
| """ |
| settings = get_settings() |
| async with _lock: |
| data = await _load_admins() |
| if data["admins"]: |
| logger.info("admins.json already has %d admin(s), skipping seed.", len(data["admins"])) |
| return |
|
|
| |
| pw_hash, pw_salt = hash_password(settings.admin_pass) |
| now = datetime.now(timezone.utc).isoformat() |
| record = { |
| "username": settings.admin_user, |
| "role": "superadmin", |
| "email": settings.admin_email, |
| "display_name": settings.admin_user, |
| "notes": "Initial superadmin seeded from .env", |
| "password_hash": pw_hash, |
| "password_salt": pw_salt, |
| "password_changed_at": now, |
| "permissions": list(_TOP_LEVEL_PERMISSIONS), |
| "is_active": True, |
| "created_at": now, |
| "created_by": "system", |
| "last_login": None, |
| "last_active": None, |
| "failed_attempts": 0, |
| "locked_until": None, |
| "force_logout_at": None, |
| } |
| data["admins"].append(record) |
| _append_audit(data, "system", "seed_admin", settings.admin_user, |
| "Initial superadmin seeded from environment") |
| await _save_admins(data) |
|
|
| logger.info("Seeded initial superadmin: %s", settings.admin_user) |
|
|
|
|
| |
|
|
| async def get_templates() -> dict: |
| """Return all templates (built-in + custom).""" |
| async with _lock: |
| data = await _load_admins() |
| custom = data.get("templates", {}) |
| |
| result = dict(PERMISSION_TEMPLATES) |
| result.update(custom) |
| return result |
|
|
|
|
| async def save_custom_template(template_id: str, name: str, permissions: List[str], actor: str) -> None: |
| """Save a custom permission template.""" |
| async with _lock: |
| data = await _load_admins() |
| data.setdefault("templates", {})[template_id] = { |
| "name": name, |
| "permissions": permissions, |
| } |
| _append_audit(data, actor, "save_template", template_id, |
| f"Saved custom template '{name}'") |
| await _save_admins(data) |
|
|
|
|
| |
|
|
| def _find_admin(data: dict, username: str) -> Optional[dict]: |
| """Find admin by username (case-insensitive). Returns mutable reference.""" |
| username_lower = username.lower() |
| for admin in data["admins"]: |
| if admin["username"].lower() == username_lower: |
| return admin |
| return None |
|
|
|
|
| def _validate_permissions(permissions: List[str]) -> None: |
| """ |
| Validate sub-permission consistency. |
| E.g., sessions.reply requires sessions.view. |
| """ |
| perm_set = set(permissions) |
| for parent, info in _SUB_PERMISSION_HIERARCHY.items(): |
| children_present = [c for c in info["children"] if c in perm_set] |
| if children_present and parent not in perm_set: |
| raise ValueError( |
| f"Sub-permissions {children_present} require parent permission '{parent}'." |
| ) |
| base = info["base"] |
| non_base = [c for c in children_present if c != base] |
| if non_base and base not in perm_set: |
| raise ValueError( |
| f"Sub-permission(s) {non_base} require '{base}' to be enabled." |
| ) |
|
|
|
|
| def has_permission(admin: dict, permission: str) -> bool: |
| """Check if admin has a specific permission. Superadmins always pass.""" |
| if admin.get("role") == "superadmin": |
| return True |
| return permission in admin.get("permissions", []) |
|
|
|
|
| def has_any_settings_permission(admin: dict) -> bool: |
| """Check if admin has at least one settings-related sub-permission.""" |
| if admin.get("role") == "superadmin": |
| return True |
| perms = set(admin.get("permissions", [])) |
| |
| if any(p.startswith("whatsapp.") for p in perms): |
| return True |
| |
| if any(p in perms for p in _SETTINGS_SUB_PERMISSIONS): |
| return True |
| return False |
|
|