Some checks failed
CI / changes (push) Successful in 6s
CI / frontend-tests (push) Has been skipped
CI / changes (pull_request) Successful in 6s
Deploy Trade-In / changes (push) Successful in 7s
Deploy / changes (push) Successful in 6s
CI / frontend-tests (pull_request) Has been skipped
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 36s
Deploy / build-backend (push) Successful in 1m39s
Deploy / build-frontend (push) Has been skipped
Deploy Trade-In / build-backend (push) Successful in 48s
Deploy / build-worker (push) Successful in 2m41s
Deploy / deploy (push) Failing after 4s
Deploy Trade-In / deploy (push) Successful in 47s
CI / backend-tests (push) Successful in 6m38s
CI / backend-tests (pull_request) Successful in 6m30s
EPIC18/§19. analyst sees everything (deals, insights, exports, site-finder, analytics, concept) EXCEPT admin/data-management. Enforcement is backend-hard (the existing rbac_guard already 403s any non-admin role on /api/v1/admin/*, so adding the role auto-blocks it) + frontend (deny_paths via /me) + audit. §19 audit: new best-effort HTTP middleware logs the sensitive actions (analyze / forecast / forecast-export) to a new audit_log table after the response. Audit failures never break or delay-fail the request (2-layer try/except + finally close). Registered INNER to rbac_guard so only authorized requests are audited (a 403 short-circuits before audit). classify_path matches export before forecast (anchored). - auth/roles.yaml: analyst role (paths /**, deny admin-mgmt) + analysttest QA user - core/auth.py (+ tradein mirror): Role Literal += analyst - core/audit_middleware.py (new) + main.py registration - data/sql/144_audit_log.sql (idempotent; auto-applies) - tests: analyst rbac (403 admin / 200 parcels) + 11 audit cases No data-level ACL (analyst sees data per policy) -> no #948 dependency. No new deps. parcels.py untouched. Real analyst logins still need adding to caddy/users.caddy.snippet (devops). Refs #962.
196 lines
6.8 KiB
Python
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", "analyst"]
|
|
|
|
|
|
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()
|