gendesign/backend/app/core/auth.py
Light1YT 88cdfd6adb
All checks were successful
Deploy Trade-In / changes (push) Successful in 5s
Deploy / changes (push) Successful in 6s
Deploy Trade-In / build-frontend (push) Successful in 27s
Deploy / build-frontend (push) Successful in 30s
Deploy Trade-In / build-backend (push) Successful in 1m8s
Deploy Trade-In / deploy (push) Successful in 45s
Deploy / build-backend (push) Successful in 2m40s
Deploy / build-worker (push) Successful in 3m20s
Deploy / deploy (push) Successful in 1m16s
feat(rbac): role-based access control via X-Authenticated-User middleware (#585)
Backend RBAC + Caddy header_up. Closes part of #fixes-rbac-pra.
2026-05-26 06:18:40 +00:00

196 lines
6.8 KiB
Python

"""Role-based access control (RBAC) — load `auth/roles.yaml` and answer
authorisation questions.
Caddy gates the whole site with basic_auth (см. `caddy/users.caddy.snippet`)
и пропускает в backend заголовок `X-Authenticated-User: <username>` через
`header_up X-Authenticated-User {http.auth.user.id}` в каждом reverse_proxy.
Этот модуль читает yaml-конфиг **один раз при импорте** и отдаёт чистые
функции — middleware и endpoint /me потом гоняют их per-request.
Source-of-truth file:
- container: /app/auth/roles.yaml (bind-mount из репо)
- repo: auth/roles.yaml
Если файл не найден — модуль падает на import (без RBAC сервис стартовать
не должен; лучше "не стартовать" чем "молча пропустить всех в /admin").
Mirror lives in tradein-mvp/backend/app/core/auth.py — синхронизация
руками; rationale в комментарии того файла.
"""
from __future__ import annotations
import logging
import re
from functools import lru_cache
from pathlib import Path
from typing import Literal, TypedDict
import yaml
logger = logging.getLogger(__name__)
Role = Literal["admin", "pilot"]
class UserScope(TypedDict):
"""Возвращается /me — фронт может использовать allowed_paths для UI gating."""
username: str
role: Role
allowed_paths: list[str]
deny_paths: list[str]
# ---------------------------------------------------------------------------
# YAML loading
# ---------------------------------------------------------------------------
def _find_roles_yaml() -> Path:
"""Find `roles.yaml` either in the container mount path or in the repo root.
Container layout (prod): `/app/auth/roles.yaml` (bind-mount).
Dev layout: `<repo>/auth/roles.yaml` — backend code lives in `<repo>/backend/app`
so we walk up from this file to find the first ancestor containing `auth/`.
"""
container_path = Path("/app/auth/roles.yaml")
if container_path.is_file():
return container_path
# Walk up from this file to find <repo>/auth/roles.yaml.
here = Path(__file__).resolve()
for ancestor in here.parents:
candidate = ancestor / "auth" / "roles.yaml"
if candidate.is_file():
return candidate
raise FileNotFoundError(
"roles.yaml not found in /app/auth/ or in any ancestor of "
f"{here} — RBAC cannot start without it"
)
@lru_cache(maxsize=1)
def _load_roles_config() -> dict:
"""Parse `roles.yaml` once and cache the result for the process lifetime."""
path = _find_roles_yaml()
try:
with path.open(encoding="utf-8") as fh:
data = yaml.safe_load(fh)
except Exception:
logger.exception("failed to parse roles.yaml at %s", path)
raise
if not isinstance(data, dict) or "roles" not in data or "users" not in data:
raise ValueError(f"roles.yaml at {path} must have top-level keys 'roles' and 'users'")
# Cheap sanity-check: each user maps to a role that exists.
roles = data["roles"]
users = data["users"]
for username, role in users.items():
if role not in roles:
raise ValueError(
f"user {username!r} maps to unknown role {role!r} (known: {list(roles)})"
)
logger.info(
"RBAC loaded from %s%d users, %d roles",
path,
len(users),
len(roles),
)
return data
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
def get_role(username: str) -> Role:
"""Return the role for *username* or raise KeyError if unknown."""
config = _load_roles_config()
users: dict[str, Role] = config["users"]
if username not in users:
raise KeyError(f"user {username!r} not in roles config")
return users[username]
def _glob_to_regex(pattern: str) -> re.Pattern[str]:
"""Compile a glob pattern with `**` semantics to a regex.
Semantics we want:
`/**` → matches everything (admin scope).
`/foo/**` → matches `/foo`, `/foo/`, `/foo/bar`, `/foo/bar/baz`, ...
`/foo/*` → matches one segment after `/foo/` (no slashes).
`/foo` → matches exactly `/foo`.
Python's `fnmatch` doesn't distinguish `*` and `**` — we use a small custom
translator with placeholders so we can do it ourselves without false sharing.
"""
# Sentinels chosen to never appear in a real path or in glob metachars.
dstar = "\x00DSTAR\x00"
sstar = "\x00SSTAR\x00"
work = pattern.replace("**", dstar).replace("*", sstar)
regex = re.escape(work)
# `**` → match anything (slashes allowed). `/**` at the end also matches the
# parent dir without a trailing slash, e.g. `/admin/**` matches `/admin`.
regex = regex.replace(re.escape(dstar), ".*")
regex = regex.replace(re.escape(sstar), "[^/]*")
# Special-case `/foo/**` → also match `/foo` (no trailing segment) so
# `/admin/**` covers a bare `/admin` request — that's what callers expect.
if regex.endswith("/.*"):
regex = regex[: -len("/.*")] + r"(?:/.*)?"
return re.compile(f"^{regex}$")
@lru_cache(maxsize=512)
def _compile_globs(patterns: tuple[str, ...]) -> tuple[re.Pattern[str], ...]:
return tuple(_glob_to_regex(p) for p in patterns)
def is_path_allowed(role: str, path: str) -> bool:
"""Check whether *role* may access *path*.
A path is allowed iff it matches at least one entry in `paths` AND does
not match any entry in `deny`. `deny` is final — admin role uses an empty
deny list so it always wins.
"""
config = _load_roles_config()
roles = config["roles"]
if role not in roles:
return False
role_def = roles[role]
allow_globs = _compile_globs(tuple(role_def.get("paths", [])))
deny_globs = _compile_globs(tuple(role_def.get("deny", []) or []))
# Deny overrides allow.
if any(g.match(path) for g in deny_globs):
return False
return any(g.match(path) for g in allow_globs)
def get_user_scope(username: str) -> UserScope:
"""Return everything a frontend needs to do UI-level gating for *username*.
Raises KeyError if *username* is unknown.
"""
config = _load_roles_config()
role = get_role(username)
role_def = config["roles"][role]
return UserScope(
username=username,
role=role,
allowed_paths=list(role_def.get("paths", []) or []),
deny_paths=list(role_def.get("deny", []) or []),
)
def reset_cache_for_tests() -> None:
"""Drop cached YAML — only for unit tests that patch the file path."""
_load_roles_config.cache_clear()
_compile_globs.cache_clear()