gendesign/tradein-mvp/backend/app/core/auth.py
bot-backend dafd12d9fa
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
feat(tradein/v2): реальный display-профиль в TopNav через /me (#2046)
/me теперь отдаёт display_name/org/email (kopylov -> "Копылов", остальные None).
TopNav использует их вместо фабрикации username-as-name; org/email фолбэк
остаётся прежним для юзеров без известного профиля.
2026-07-03 22:55:12 +03:00

237 lines
8.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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()