chatRAG / app /services /admin_store.py
joedown11's picture
fix: WA number stored without +, fix ProviderFactory.get_provider and send_message calls
05183df
Raw
History Blame Contribute Delete
31.2 kB
"""
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__)
# ── Constants ─────────────────────────────────────────────────────────────────
_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: parent -> required base sub-permission
_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 (no parent toggle β€” Settings is composite)
_SETTINGS_SUB_PERMISSIONS = [
"settings.notifications", "settings.config",
"settings.security", "settings.version",
]
# All valid top-level permission keys
_TOP_LEVEL_PERMISSIONS = [
"manage_sessions", "manage_whatsapp", "view_analytics",
"manage_admins", "manage_kb",
]
# Built-in permission templates
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",
],
},
}
# ── Module-level lock and state ───────────────────────────────────────────────
_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"
# ── Password Hashing ─────────────────────────────────────────────────────────
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)
# ── File I/O (atomic) ────────────────────────────────────────────────────────
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)
# Ensure required keys
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 wrappers ────────────────────────────────────────────────────────────
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)
# ── Audit Log ─────────────────────────────────────────────────────────────────
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)
# Prune oldest if over cap
if len(data["audit_log"]) > _MAX_AUDIT_LOG:
data["audit_log"] = data["audit_log"][-_MAX_AUDIT_LOG:]
# ── Core Functions ────────────────────────────────────────────────────────────
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."""
# Validate username
if not _USERNAME_RE.match(username):
raise ValueError("Username must be 3-32 alphanumeric/underscore characters.")
# Validate password
if len(password) < 8:
raise ValueError("Password must be at least 8 characters.")
# Validate role
if role not in ("superadmin", "sub_admin"):
raise ValueError("Role must be 'superadmin' or 'sub_admin'.")
# Validate permissions for 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 sub-permission consistency
_validate_permissions(permissions)
async with _lock:
data = await _load_admins()
# Check duplicate username (case-insensitive)
username_lower = username.lower()
for admin in data["admins"]:
if admin["username"].lower() == username_lower:
raise ValueError(f"Username '{username}' already exists.")
# Check duplicate email
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.")
# Hash password
pw_hash, pw_salt = hash_password(password)
now = datetime.now(timezone.utc).isoformat()
# Superadmin gets all top-level permissions
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 = []
# Role change
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'.")
# Check: demoting last superadmin
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 promoted to superadmin, set all top-level permissions
if role == "superadmin":
admin["permissions"] = list(_TOP_LEVEL_PERMISSIONS)
# Email change
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
# Permissions change (only for sub_admin)
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)
# Display name
if display_name is not None:
admin["display_name"] = display_name
# Notes
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()
# Strip leading + if present
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.")
# Cannot delete self
if username.lower() == requesting_user.lower():
raise ValueError("Cannot delete your own account.")
# Cannot delete last superadmin
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:
# .env fallback: if username matches and admins.json is empty or missing
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
# Check suspended
if not admin.get("is_active", True):
raise PermissionError("Your account has been suspended. Contact your superadmin.")
# Check locked
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:
# Lock expired, reset
admin["locked_until"] = None
admin["failed_attempts"] = 0
except (ValueError, TypeError):
pass
# Verify password
verified = _verify_hash(password, admin["password_hash"], admin["password_salt"])
if not verified:
# .env fallback for the seeded superadmin
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
# Success β€” reset failed attempts
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()
# Verify old password
old_ok = _verify_hash(old_password, admin["password_hash"], admin["password_salt"])
# Also check .env fallback
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.")
# Set new password
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.")
# Cannot suspend self
if username.lower() == requesting_user.lower():
raise ValueError("Cannot suspend your own account.")
# Cannot suspend last superadmin
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 # Never logged in but has a force_logout β€” force it
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", [])
# Return newest first
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
# Seed from .env
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)
# ── Permission Templates ─────────────────────────────────────────────────────
async def get_templates() -> dict:
"""Return all templates (built-in + custom)."""
async with _lock:
data = await _load_admins()
custom = data.get("templates", {})
# Merge built-in and custom
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)
# ── Helper functions ──────────────────────────────────────────────────────────
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", []))
# Check whatsapp sub-perms
if any(p.startswith("whatsapp.") for p in perms):
return True
# Check settings sub-perms
if any(p in perms for p in _SETTINGS_SUB_PERMISSIONS):
return True
return False