gendesign/frontend/src/lib/isPathAllowed.ts
Light1YT 7a2b055b35
All checks were successful
Deploy / changes (push) Successful in 5s
Deploy Trade-In / changes (push) Successful in 5s
Deploy Trade-In / build-backend (push) Has been skipped
Deploy / build-backend (push) Has been skipped
Deploy / build-worker (push) Has been skipped
Deploy Trade-In / build-frontend (push) Successful in 1m42s
Deploy Trade-In / deploy (push) Successful in 39s
Deploy / build-frontend (push) Successful in 2m55s
Deploy / deploy (push) Successful in 1m1s
feat(rbac): frontend route-guard + nav filter (#586)
PR B (frontend) to backend RBAC #585. RouteGuard wraps app, NoAccessScreen on 403 from /me, TopNav filtered by role.
2026-05-26 06:51:45 +00:00

79 lines
3.3 KiB
TypeScript

/**
* JS-порт `backend/app/core/auth.py::is_path_allowed`.
*
* Backend — source of truth: глобы из `auth/roles.yaml` парсятся в regex
* по тем же правилам что и здесь. Фронт использует это только для UI-gating
* (фильтр top-nav, route-guard перед навигацией) — реальная проверка
* по-прежнему происходит в backend middleware на каждый /api/v1/* request.
*
* Семантика glob (mirror of `_glob_to_regex`):
* `/**` → matches everything (admin scope)
* `/foo/**` → matches `/foo`, `/foo/`, `/foo/bar`, `/foo/bar/baz`, …
* `/foo/*` → matches one segment after `/foo/` (no slashes)
* `/foo` → exact match only
*
* Path allowed iff (a) matches >=1 allow-pattern AND (b) doesn't match any
* deny-pattern. Deny final (admin uses empty deny → всегда побеждает).
*/
const REGEX_META = /[.+^${}()|[\]\\]/g;
function escapeRegex(s: string): string {
return s.replace(REGEX_META, "\\$&");
}
function globToRegex(pattern: string): RegExp {
// Sentinels — никогда не встречаются в реальном path или в glob-метасимволах.
const dstar = "\x00DSTAR\x00";
const sstar = "\x00SSTAR\x00";
const work = pattern.replace(/\*\*/g, dstar).replace(/\*/g, sstar);
let regex = escapeRegex(work);
regex = regex.replace(new RegExp(escapeRegex(dstar), "g"), ".*");
regex = regex.replace(new RegExp(escapeRegex(sstar), "g"), "[^/]*");
// Special-case `/foo/**` → также матчит `/foo` (без trailing-сегмента),
// чтобы `/admin/**` покрывал bare `/admin` request — что и ждут callers.
if (regex.endsWith("/.*")) {
regex = regex.slice(0, -"/.*".length) + "(?:/.*)?";
}
return new RegExp(`^${regex}$`);
}
// Кэш скомпилированных regex — UI часто гоняет isPathAllowed на каждый рендер
// nav. patterns массив маленький и стабильный per-session, поэтому Map по
// pattern-строке достаточно.
const REGEX_CACHE = new Map<string, RegExp>();
function compileGlob(pattern: string): RegExp {
const cached = REGEX_CACHE.get(pattern);
if (cached) return cached;
const compiled = globToRegex(pattern);
REGEX_CACHE.set(pattern, compiled);
return compiled;
}
/**
* Mirror of backend `is_path_allowed(role, path)`.
*
* @param allowedPaths — глобы из `/api/v1/me` `allowed_paths`
* @param denyPaths — глобы из `/api/v1/me` `deny_paths`
* @param path — путь который пытаемся открыть (например `/admin/scrape`)
*
* Note: role-аргумент не нужен — allow/deny уже резолвлены backend'ом для
* текущего юзера и приехали в /me. Этот хелпер чистый list-matcher.
*/
export function isPathAllowed(
allowedPaths: readonly string[],
denyPaths: readonly string[],
path: string,
): boolean {
// Deny overrides allow — даже если path в allowed, deny-match блокирует.
for (const p of denyPaths) {
if (compileGlob(p).test(path)) return false;
}
for (const p of allowedPaths) {
if (compileGlob(p).test(path)) return true;
}
return false;
}