All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Successful in 10s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Successful in 1m37s
CI Trade-In / backend-tests (pull_request) Successful in 2m0s
/me теперь отдаёт display_name/org/email (kopylov -> "Копылов", остальные None). TopNav использует их вместо фабрикации username-as-name; org/email фолбэк остаётся прежним для юзеров без известного профиля.
237 lines
8.9 KiB
Python
237 lines
8.9 KiB
Python
"""Role-based access control (RBAC) — MIRROR of main backend's
|
||
``app/core/auth.py``.
|
||
|
||
Kept in sync manually; rationale: separate stacks, shared YAML config mounted
|
||
from repo root. We deliberately do NOT share code between repos via
|
||
``sys.path`` tricks — each docker stack ships its own image with its own
|
||
``app/`` tree.
|
||
|
||
When updating one copy, update the other.
|
||
|
||
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
|
||
"""
|
||
|
||
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", "analyst", "expired"]
|
||
|
||
|
||
class UserScope(TypedDict):
|
||
"""Возвращается /me — фронт может использовать allowed_paths для UI gating."""
|
||
|
||
username: str
|
||
role: Role
|
||
allowed_paths: list[str]
|
||
deny_paths: list[str]
|
||
# #657 white-label: brand slug, привязанный к аккаунту. None = generic UI.
|
||
# Фронт (useBrand.ts) применяет бренд автоматически на login (без ?brand=).
|
||
brand: str | None
|
||
# #2046 real profile fields для TopNav (фамилия/орг/email). None = фронт
|
||
# фолбэкается на username/role (как раньше) — фолбэк остаётся на фронте.
|
||
display_name: str | None
|
||
org: str | None
|
||
email: str | None
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Account → brand mapping (#657 brand-by-account)
|
||
# ---------------------------------------------------------------------------
|
||
#
|
||
# White-label бренд «Практика» снят целиком (бизнес-решение 2026-06-19): строка
|
||
# удалена из таблицы `brands` (миграция 121), SVG-логотип убран из фронта.
|
||
# Маппинг аккаунт→бренд теперь пустой — все юзеры резолвятся в None (generic
|
||
# UI/PDF). Механизм сохранён для будущих white-label клиентов: добавить slug
|
||
# сюда + строку в `brands` (см. services/brand.py). NB: аккаунты praktika/
|
||
# kopylov остаются пилот-логинами (роль `pilot` в roles.yaml) — это РОЛЬ, не
|
||
# бренд, не трогаем.
|
||
_USERNAME_BRAND: dict[str, str] = {}
|
||
|
||
|
||
def get_brand_for_user(username: str) -> str | None:
|
||
"""Return the brand slug bound to *username*, or None for generic UI."""
|
||
return _USERNAME_BRAND.get(username)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Account → profile mapping (#2046 real profile fields for TopNav)
|
||
# ---------------------------------------------------------------------------
|
||
#
|
||
# Реальные имя/организация/email пилотов для TopNav-меню (замена фабрикованного
|
||
# "username as name"). Известные данные — только display_name для kopylov
|
||
# (фамилия «Копылов» из комментария в caddy/users.caddy.snippet). org/email для
|
||
# него не задокументированы нигде — умышленно НЕ выдумываем, оставляем None,
|
||
# фронт держит текущий фолбэк (username / brand ?? role / "").
|
||
_USERNAME_PROFILE: dict[str, dict[str, str]] = {
|
||
"kopylov": {"display_name": "Копылов"},
|
||
}
|
||
|
||
|
||
def get_profile_for_user(username: str) -> tuple[str | None, str | None, str | None]:
|
||
"""Return (display_name, org, email) bound to *username*, or all-None."""
|
||
profile = _USERNAME_PROFILE.get(username, {})
|
||
return profile.get("display_name"), profile.get("org"), profile.get("email")
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 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: walk up from this file to find the first ancestor containing
|
||
`auth/`. Tradein-mvp lives inside the main repo, so the same walk finds
|
||
`<repo>/auth/roles.yaml`.
|
||
"""
|
||
container_path = Path("/app/auth/roles.yaml")
|
||
if container_path.is_file():
|
||
return container_path
|
||
|
||
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'")
|
||
|
||
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:
|
||
`/**` → matches everything (admin scope).
|
||
`/foo/**` → matches `/foo`, `/foo/`, `/foo/bar`, `/foo/bar/baz`.
|
||
`/foo/*` → matches one segment after `/foo/`.
|
||
`/foo` → matches exactly `/foo`.
|
||
"""
|
||
dstar = "\x00DSTAR\x00"
|
||
sstar = "\x00SSTAR\x00"
|
||
|
||
work = pattern.replace("**", dstar).replace("*", sstar)
|
||
regex = re.escape(work)
|
||
regex = regex.replace(re.escape(dstar), ".*")
|
||
regex = regex.replace(re.escape(sstar), "[^/]*")
|
||
|
||
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*.
|
||
|
||
Allowed iff matches `paths` AND not in `deny`. `deny` is final.
|
||
"""
|
||
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 []))
|
||
|
||
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]
|
||
display_name, org, email = get_profile_for_user(username)
|
||
return UserScope(
|
||
username=username,
|
||
role=role,
|
||
allowed_paths=list(role_def.get("paths", []) or []),
|
||
deny_paths=list(role_def.get("deny", []) or []),
|
||
brand=get_brand_for_user(username),
|
||
display_name=display_name,
|
||
org=org,
|
||
email=email,
|
||
)
|
||
|
||
|
||
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()
|