Bot & Automation

jorgasisten

/root/hermes-projects/jorgasisten

apps/jorgasisten/services/access_control.py text
import json
import re
from dataclasses import dataclass
from datetime import UTC, datetime
from pathlib import Path
from typing import Any, Literal

from jorgasisten.core.config import Settings, get_settings
from jorgasisten.services.text import safe_string

AccessRole = Literal["guest", "viewer", "operator", "admin"]

ROLE_GUEST: AccessRole = "guest"
ROLE_VIEWER: AccessRole = "viewer"
ROLE_OPERATOR: AccessRole = "operator"
ROLE_ADMIN: AccessRole = "admin"
PERSISTED_ROLES = {ROLE_VIEWER, ROLE_OPERATOR}
PASSWORD_HINT_RE = re.compile(
    r"^(?:/login(?:@\w+)?|login|masuk|password(?:\s+akses)?|akses)\s*[:=]?\s+(.+)$",
    re.IGNORECASE,
)


@dataclass(frozen=True)
class AccessSession:
    role: AccessRole
    authenticated_at: str


class AccessControlService:
    def __init__(self, settings: Settings | None = None) -> None:
        self.settings = settings or get_settings()
        self.path = Path(self.settings.user_access_registry_path)

    def get_role(self, user_id: int | str | None) -> AccessRole:
        if user_id is None:
            return ROLE_GUEST
        # If user_id is a string that is a numeric admin ID, handle it
        if isinstance(user_id, int) and user_id in self.settings.admin_ids:
            return ROLE_ADMIN
        if isinstance(user_id, str) and user_id.isdigit() and int(user_id) in self.settings.admin_ids:
            return ROLE_ADMIN
        state = self._load_state()
        session = state["users"].get(str(user_id), {})
        role = safe_string(session.get("role")).lower()
        if role in PERSISTED_ROLES:
            return role  # type: ignore[return-value]
        return ROLE_GUEST

    def get_session(self, user_id: int | str | None) -> AccessSession | None:
        role = self.get_role(user_id)
        if role == ROLE_GUEST:
            return None
        if role == ROLE_ADMIN:
            return AccessSession(role=ROLE_ADMIN, authenticated_at="-")
        state = self._load_state()
        payload = state["users"].get(str(user_id), {})
        return AccessSession(
            role=role,
            authenticated_at=safe_string(payload.get("authenticated_at")) or current_timestamp(),
        )

    def authenticate(self, user_id: int | str | None, password: str) -> AccessRole:
        if user_id is None:
            return ROLE_GUEST

        normalized_password = safe_string(password)
        if not normalized_password:
            return ROLE_GUEST

        matched_role: AccessRole = ROLE_GUEST
        if normalized_password == self.settings.operator_access_password and self.settings.operator_access_password:
            matched_role = ROLE_OPERATOR
        elif normalized_password == self.settings.viewer_access_password and self.settings.viewer_access_password:
            matched_role = ROLE_VIEWER

        if matched_role == ROLE_GUEST:
            return ROLE_GUEST

        state = self._load_state()
        state["users"][str(user_id)] = {
            "role": matched_role,
            "authenticated_at": current_timestamp(),
        }
        self._save_state(state)
        return matched_role

    def logout(self, user_id: int | str | None) -> None:
        if user_id is None:
            return
        state = self._load_state()
        state["users"].pop(str(user_id), None)
        self._save_state(state)

    def _load_state(self) -> dict[str, Any]:
        state: dict[str, Any] = {"users": {}}
        if self.path.exists():
            loaded = json.loads(self.path.read_text(encoding="utf-8"))
            if isinstance(loaded, dict):
                state.update(loaded)
        users = state.get("users")
        if not isinstance(users, dict):
            state["users"] = {}
        return state

    def _save_state(self, state: dict[str, Any]) -> None:
        self.path.parent.mkdir(parents=True, exist_ok=True)
        payload = {"users": state.get("users", {})}
        tmp_path = self.path.with_suffix(self.path.suffix + ".tmp")
        tmp_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
        tmp_path.replace(self.path)


def extract_password_candidate(text: str) -> str:
    normalized = safe_string(text)
    if not normalized:
        return ""
    matched = PASSWORD_HINT_RE.match(normalized)
    if matched:
        return safe_string(matched.group(1))
    return normalized


def is_explicit_access_attempt(text: str, settings: Settings | None = None) -> bool:
    normalized = safe_string(text)
    if not normalized:
        return False
    if PASSWORD_HINT_RE.match(normalized):
        return True
    active_settings = settings or get_settings()
    configured_passwords = {
        safe_string(active_settings.operator_access_password),
        safe_string(active_settings.viewer_access_password),
    }
    configured_passwords.discard("")
    return normalized in configured_passwords


def current_timestamp() -> str:
    return datetime.now(UTC).strftime("%Y-%m-%d %H:%M:%S UTC")


def is_authenticated_role(role: AccessRole) -> bool:
    return role != ROLE_GUEST


def can_read_data(role: AccessRole) -> bool:
    return role in {ROLE_VIEWER, ROLE_OPERATOR, ROLE_ADMIN}


def can_write_data(role: AccessRole) -> bool:
    return role in {ROLE_OPERATOR, ROLE_ADMIN}


def can_delete_data(role: AccessRole) -> bool:
    return role in {ROLE_OPERATOR, ROLE_ADMIN}


def can_manage_structure(role: AccessRole) -> bool:
    return role in {ROLE_OPERATOR, ROLE_ADMIN}


def can_manage_spreadsheets(role: AccessRole) -> bool:
    return role in {ROLE_OPERATOR, ROLE_ADMIN}