Add multi-account command ACL system
This commit is contained in:
161
app/commands/acl.py
Normal file
161
app/commands/acl.py
Normal file
@@ -0,0 +1,161 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from tempfile import NamedTemporaryFile
|
||||
from threading import RLock
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _normalize_command(value: str) -> str:
|
||||
token = value.strip().lower()
|
||||
token = token.lstrip(".")
|
||||
if "@" in token:
|
||||
token = token.split("@", 1)[0]
|
||||
return token
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class RuntimeAclStore:
|
||||
path: Path
|
||||
data: dict[str, dict[str, set[str]]] = field(init=False)
|
||||
_lock: RLock = field(init=False, repr=False)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self._lock = RLock()
|
||||
self.data = {"allow": {}, "deny": {}}
|
||||
self.load()
|
||||
|
||||
def load(self) -> None:
|
||||
with self._lock:
|
||||
if not self.path.exists():
|
||||
return
|
||||
|
||||
try:
|
||||
raw = json.loads(self.path.read_text(encoding="utf-8"))
|
||||
except json.JSONDecodeError:
|
||||
log.warning("Ignoring malformed ACL file: %s", self.path)
|
||||
return
|
||||
|
||||
if not isinstance(raw, dict):
|
||||
log.warning("Ignoring ACL file with unexpected root type: %s", self.path)
|
||||
return
|
||||
|
||||
allow_raw = raw.get("allow", {})
|
||||
deny_raw = raw.get("deny", {})
|
||||
self.data = {
|
||||
"allow": {
|
||||
str(user_id): {str(command) for command in commands}
|
||||
for user_id, commands in allow_raw.items()
|
||||
},
|
||||
"deny": {
|
||||
str(user_id): {str(command) for command in commands}
|
||||
for user_id, commands in deny_raw.items()
|
||||
},
|
||||
}
|
||||
|
||||
def save(self) -> None:
|
||||
with self._lock:
|
||||
payload = {
|
||||
"version": 1,
|
||||
"allow": {
|
||||
user_id: sorted(commands)
|
||||
for user_id, commands in sorted(self.data["allow"].items())
|
||||
if commands
|
||||
},
|
||||
"deny": {
|
||||
user_id: sorted(commands)
|
||||
for user_id, commands in sorted(self.data["deny"].items())
|
||||
if commands
|
||||
},
|
||||
}
|
||||
|
||||
serialized = json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True)
|
||||
with NamedTemporaryFile(
|
||||
"w",
|
||||
encoding="utf-8",
|
||||
delete=False,
|
||||
dir=self.path.parent,
|
||||
prefix=self.path.name,
|
||||
suffix=".tmp",
|
||||
) as handle:
|
||||
handle.write(serialized)
|
||||
temp_path = Path(handle.name)
|
||||
temp_path.replace(self.path)
|
||||
|
||||
def _allow_set(self, user_id: int) -> set[str]:
|
||||
return self.data["allow"].setdefault(str(user_id), set())
|
||||
|
||||
def _deny_set(self, user_id: int) -> set[str]:
|
||||
return self.data["deny"].setdefault(str(user_id), set())
|
||||
|
||||
def allow(self, user_id: int, command: str) -> bool:
|
||||
with self._lock:
|
||||
command_name = _normalize_command(command)
|
||||
if not command_name:
|
||||
return False
|
||||
|
||||
allow_set = self._allow_set(user_id)
|
||||
deny_set = self._deny_set(user_id)
|
||||
changed = command_name not in allow_set or command_name in deny_set
|
||||
allow_set.add(command_name)
|
||||
deny_set.discard(command_name)
|
||||
if changed:
|
||||
self.save()
|
||||
return changed
|
||||
|
||||
def deny(self, user_id: int, command: str) -> bool:
|
||||
with self._lock:
|
||||
command_name = _normalize_command(command)
|
||||
if not command_name:
|
||||
return False
|
||||
|
||||
allow_set = self._allow_set(user_id)
|
||||
deny_set = self._deny_set(user_id)
|
||||
changed = command_name not in deny_set or command_name in allow_set
|
||||
deny_set.add(command_name)
|
||||
allow_set.discard(command_name)
|
||||
if changed:
|
||||
self.save()
|
||||
return changed
|
||||
|
||||
def is_allowed(self, user_id: int, command: str) -> bool:
|
||||
with self._lock:
|
||||
command_name = _normalize_command(command)
|
||||
user_key = str(user_id)
|
||||
deny_set = self.data["deny"].get(user_key, set())
|
||||
if command_name in deny_set:
|
||||
return False
|
||||
allow_set = self.data["allow"].get(user_key, set())
|
||||
return command_name in allow_set
|
||||
|
||||
def list_allowed(self, user_id: int) -> list[str]:
|
||||
with self._lock:
|
||||
return sorted(self.data["allow"].get(str(user_id), set()))
|
||||
|
||||
def list_denied(self, user_id: int) -> list[str]:
|
||||
with self._lock:
|
||||
return sorted(self.data["deny"].get(str(user_id), set()))
|
||||
|
||||
def count_allowed_users(self, command: str) -> int:
|
||||
with self._lock:
|
||||
command_name = _normalize_command(command)
|
||||
return sum(1 for commands in self.data["allow"].values() if command_name in commands)
|
||||
|
||||
def count_denied_users(self, command: str) -> int:
|
||||
with self._lock:
|
||||
command_name = _normalize_command(command)
|
||||
return sum(1 for commands in self.data["deny"].values() if command_name in commands)
|
||||
|
||||
def stats(self) -> dict[str, int]:
|
||||
with self._lock:
|
||||
return {
|
||||
"allowed_users": len(self.data["allow"]),
|
||||
"denied_users": len(self.data["deny"]),
|
||||
"allow_rules": sum(len(commands) for commands in self.data["allow"].values()),
|
||||
"deny_rules": sum(len(commands) for commands in self.data["deny"].values()),
|
||||
}
|
||||
Reference in New Issue
Block a user