diff --git a/Dockerfile b/Dockerfile index 61e2474..ab346c5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,6 +3,7 @@ WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY main.py . +COPY auth.py . COPY static ./static EXPOSE 8000 CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/auth.py b/auth.py new file mode 100644 index 0000000..b09e7bf --- /dev/null +++ b/auth.py @@ -0,0 +1,59 @@ +"""Keycloak JWT validation for role-based response truncation. + +The map dashboard has two tiers: + * anonymous — no token (or an invalid/expired one). Sees a truncated view. + * manager — a valid Keycloak access token carrying the realm role `manager`. + Sees the full data. + +Design rule: a MISSING or INVALID token is NOT an error — it simply means the +caller is anonymous. So role_from_authorization() never raises; the worst case +degrades to the anonymous tier. This keeps the public map working even when +Keycloak is unreachable or a token has expired. + +Validation checks the signature (RS256, keys fetched from the realm JWKS with +in-process caching + automatic rotation on unknown kid) and the issuer. The +access token's audience is `account` (Keycloak default), so we intentionally do +NOT verify `aud`; authorization is decided by the presence of the realm role. +""" + +import os + +import jwt +from jwt import PyJWKClient + +# Public realm URL — matches the `iss` Keycloak stamps into the token, so issuer +# validation lines up without extra config. Overridable via env for other envs. +KC_REALM_URL = os.environ.get( + "KC_REALM_URL", "https://keycloak.intense-sale.ru/realms/intense-sale" +) +KC_ISSUER = KC_REALM_URL +JWKS_URL = f"{KC_REALM_URL}/protocol/openid-connect/certs" +MANAGER_ROLE = "manager" + +# PyJWKClient caches signing keys and re-fetches when it sees an unknown `kid` +# (key rotation), so we build it once at import time. +_jwk_client = PyJWKClient(JWKS_URL) + + +def role_from_authorization(authorization: str | None) -> str: + """Return "manager" for a valid token with the manager role, else "anonymous". + + Never raises: any problem (no header, wrong scheme, bad signature, expired, + JWKS fetch failure) falls through to "anonymous". + """ + if not authorization or not authorization.lower().startswith("bearer "): + return "anonymous" + token = authorization[7:].strip() + try: + signing_key = _jwk_client.get_signing_key_from_jwt(token) + claims = jwt.decode( + token, + signing_key.key, + algorithms=["RS256"], + issuer=KC_ISSUER, + options={"verify_aud": False}, + ) + except Exception: + return "anonymous" + roles = claims.get("realm_access", {}).get("roles", []) or [] + return MANAGER_ROLE if MANAGER_ROLE in roles else "anonymous" diff --git a/frontend/index.html b/frontend/index.html index f752f8c..0d29f98 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -33,6 +33,7 @@ + diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 071a456..c69ff98 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -9,6 +9,7 @@ "version": "0.1.0", "dependencies": { "@fontsource/ibm-plex-sans": "^5.3.0", + "keycloak-js": "^26.2.4", "open-props": "^1.7.23", "vis-data": "^7.1.9", "vis-timeline": "^7.7.3" @@ -880,6 +881,15 @@ "license": "(Apache-2.0 OR MIT)", "peer": true }, + "node_modules/keycloak-js": { + "version": "26.2.4", + "resolved": "https://registry.npmjs.org/keycloak-js/-/keycloak-js-26.2.4.tgz", + "integrity": "sha512-PnXpR3ubETGOt0B/Qt2lxmPbkZr5bc3vlQsOqDoTPPQsZRp7JjhTKxlJ187uWh8qJhvBab6Gsjb06a8ayOPfuw==", + "license": "Apache-2.0", + "workspaces": [ + "test" + ] + }, "node_modules/moment": { "version": "2.30.1", "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index dce464e..1316acf 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,6 +10,7 @@ }, "dependencies": { "@fontsource/ibm-plex-sans": "^5.3.0", + "keycloak-js": "^26.2.4", "open-props": "^1.7.23", "vis-data": "^7.1.9", "vis-timeline": "^7.7.3" diff --git a/frontend/public/silent-check-sso.html b/frontend/public/silent-check-sso.html new file mode 100644 index 0000000..6bb81c2 --- /dev/null +++ b/frontend/public/silent-check-sso.html @@ -0,0 +1,10 @@ + + + + + + diff --git a/frontend/src/api.ts b/frontend/src/api.ts index c3fbe7d..d193a4b 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -1,6 +1,7 @@ // Typed wrappers around the mapdash HTTP API. All network access for the app // goes through here, so response shapes stay in one typed place. import type { Meta, Board, Booking, Filters, MapSurface } from './types'; +import { authHeader } from './auth'; function buildQuery(f: Filters): string { const params = new URLSearchParams({ @@ -17,7 +18,7 @@ function buildQuery(f: Filters): string { } async function getJson(url: string): Promise { - const res = await fetch(url); + const res = await fetch(url, { headers: await authHeader() }); if (!res.ok) { throw new Error(`Request failed: ${url} -> HTTP ${res.status}`); } diff --git a/frontend/src/auth.ts b/frontend/src/auth.ts new file mode 100644 index 0000000..e1fb69d --- /dev/null +++ b/frontend/src/auth.ts @@ -0,0 +1,69 @@ +// Keycloak (OIDC) integration. The app is anonymous by default; logging in as a +// user with the realm role `manager` unlocks the full data view. Everything here +// degrades gracefully: if Keycloak is unreachable, initAuth() resolves anyway and +// the app stays in the anonymous tier. +import Keycloak from 'keycloak-js'; + +const keycloak = new Keycloak({ + url: 'https://keycloak.intense-sale.ru', + realm: 'intense-sale', + clientId: 'mapdash', +}); + +let manager = false; + +function computeManager(): boolean { + const roles = (keycloak.tokenParsed as { realm_access?: { roles?: string[] } } | undefined) + ?.realm_access?.roles ?? []; + return keycloak.authenticated === true && roles.includes('manager'); +} + +/** + * Initialise Keycloak with a silent SSO check (no forced login). Never rejects — + * on any failure we log and continue as an anonymous visitor. + */ +export async function initAuth(): Promise { + try { + await keycloak.init({ + onLoad: 'check-sso', + silentCheckSsoRedirectUri: window.location.origin + '/silent-check-sso.html', + pkceMethod: 'S256', + }); + manager = computeManager(); + } catch (e) { + console.warn('Keycloak init failed — continuing as anonymous', e); + manager = false; + } +} + +export function isManager(): boolean { + return manager; +} + +export function isAuthenticated(): boolean { + return keycloak.authenticated === true; +} + +export function login(): void { + // Return to the current view (filters in the URL) after login. + keycloak.login({ redirectUri: window.location.href }); +} + +export function logout(): void { + keycloak.logout({ redirectUri: window.location.origin }); +} + +/** + * Authorization header for API calls. Refreshes the token when it is close to + * expiry. Returns an empty object for anonymous callers (backend then serves the + * truncated tier). + */ +export async function authHeader(): Promise> { + if (!keycloak.authenticated) return {}; + try { + await keycloak.updateToken(30); + } catch { + /* refresh failed — send the current token; backend falls back to anon if invalid */ + } + return keycloak.token ? { Authorization: `Bearer ${keycloak.token}` } : {}; +} diff --git a/frontend/src/main.ts b/frontend/src/main.ts index d9ee014..f42c864 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -19,6 +19,7 @@ import { loadAppearance, applyCssVars, buildAppearanceMenu, colorForStatus } fro import { createMapView } from './map'; import { createViewModes, type ViewMode } from './viewmode'; import { escapeHtml } from './util'; +import { initAuth, isManager, isAuthenticated, login, logout } from './auth'; const el = { search: document.getElementById('search') as HTMLInputElement, @@ -50,8 +51,35 @@ const el = { loadBar: document.getElementById('load-bar') as HTMLElement, emptyResetTimeline: document.getElementById('empty-reset-timeline') as HTMLButtonElement, emptyResetMap: document.getElementById('empty-reset-map') as HTMLButtonElement, + authBtn: document.getElementById('auth-btn') as HTMLButtonElement, }; +// ---- role-based UI (anonymous vs manager) ---- +// Backend enforces truncation; here we just hide the controls that only make +// sense for the full-data (manager) tier so the anonymous view stays clean. +function applyRoleUi(): void { + const manager = isManager(); + const hideField = (node: Element | null): void => { + const f = node?.closest('.field') as HTMLElement | null; + if (f) f.style.display = manager ? '' : 'none'; + }; + // Client-facing facets: Brand, Manager, Status. (Search stays — it filters by + // address/board code, which anonymous visitors are allowed to use.) + hideField(document.getElementById('brand-dropdown')); + hideField(document.getElementById('manager-dropdown')); + hideField(document.getElementById('status-dropdown')); + // Brand/Company toggles and the appearance (colour) menu. + const checks = document.querySelector('.checks') as HTMLElement | null; + if (checks) checks.style.display = manager ? '' : 'none'; + const decor = document.querySelector('.decor-wrap') as HTMLElement | null; + if (decor) decor.style.display = manager ? '' : 'none'; + + // Login / logout button. + el.authBtn.style.display = ''; + el.authBtn.textContent = isAuthenticated() ? 'Выйти' : 'Войти'; + el.authBtn.onclick = () => (isAuthenticated() ? logout() : login()); +} + // ---- theme toggle (data-theme is set pre-paint by the inline head script) ---- function currentTheme(): 'light' | 'dark' { return document.documentElement.getAttribute('data-theme') === 'dark' ? 'dark' : 'light'; @@ -99,6 +127,11 @@ let lastBoards: Board[] = []; let lastBookings: Booking[] = []; function flags(): RenderFlags { + // Anonymous tier: no labels (data is blanked anyway) and no collision + // highlighting, so every bar renders in the single neutral status colour. + if (!isManager()) { + return { withBrand: false, withCompany: false, withCollisions: false }; + } return { withBrand: el.showBrand.checked, withCompany: el.showCompany.checked, @@ -363,6 +396,8 @@ function closeBoardPanel(): void { boardBackdrop.hidden = true; } function openBoardPanel(boardId: string): void { + // Detail panel exposes client/brand/manager + Planfix deep-links — managers only. + if (!isManager()) return; const board = lastBoards.find((b) => b.board_id === boardId); const bookings = lastBookings .filter((b) => b.board_id === boardId) @@ -410,7 +445,14 @@ async function init(): Promise { dimensionDropdown.setValues([], 'Все размеры'); managerDropdown.setValues([], 'Все менеджеры'); statusDropdown.setValues([], 'Все статусы'); + await initAuth(); // resolve role before first data load (degrades to anon on failure) + applyRoleUi(); applyUrlToFilters(); // restore filters from a shared link + if (!isManager()) { + // A shared link may carry hidden facets; drop them so the anonymous view + // neither queries nor re-serialises Brand/Manager/Status. + for (const d of [brandDropdown, managerDropdown, statusDropdown]) d.clear(); + } rebuildDecorMenu(); view.applyLayout(); await loadData(true); diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 7586f80..b1f9a19 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -312,6 +312,15 @@ input::placeholder { color: var(--text-muted); } transition: border-color var(--transition), background var(--transition); } .theme-toggle:hover { border-color: var(--border-strong); background: var(--surface); } + +/* Login / logout button (mirrors the toggle styling, sized to its text label) */ +.auth-btn { + border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--bg); + color: var(--text); height: 34px; padding: 0 14px; font-size: 13px; font-weight: 600; + line-height: 1; cursor: pointer; display: inline-flex; align-items: center; + transition: border-color var(--transition), background var(--transition); +} +.auth-btn:hover { border-color: var(--border-strong); background: var(--surface); } /* Dark theme temporarily hidden — toggle stays in the DOM, just not shown. Re-enable by removing this rule + restoring the head script in index.html. */ .theme-toggle { display: none; } diff --git a/main.py b/main.py index 7e2dbda..71d98ef 100644 --- a/main.py +++ b/main.py @@ -1,10 +1,18 @@ import asyncio import os import httpx -from fastapi import FastAPI, Query +from fastapi import Depends, FastAPI, Header, Query from fastapi.responses import FileResponse from fastapi.staticfiles import StaticFiles +from auth import role_from_authorization + + +def get_role(authorization: str | None = Header(default=None)) -> str: + # Resolve the caller's tier from the Bearer token. "anonymous" is the safe + # default (missing/invalid token); "manager" unlocks the full data view. + return role_from_authorization(authorization) + CH_HOST = os.environ.get("CLICKHOUSE_HOST", "ClickHouse") CH_PORT = os.environ.get("CLICKHOUSE_PORT", "8123") CH_USER = os.environ.get("CLICKHOUSE_USER", "default") @@ -156,7 +164,11 @@ def parse_filters(city: str, dimension: str, brand: str, manager: str, status: s @app.get("/api/meta") -async def meta(city: str = "", dimension: str = "", brand: str = "", manager: str = "", status: str = "", search: str = "", date_start: str = "", date_end: str = ""): +async def meta(role: str = Depends(get_role), city: str = "", dimension: str = "", brand: str = "", manager: str = "", status: str = "", search: str = "", date_start: str = "", date_end: str = ""): + # Anonymous tier: the Brand/Manager/Status facets are hidden, so ignore those + # incoming filters (a crafted request can't use them to probe hidden data). + if role != "manager": + brand = manager = status = "" params = parse_filters(city, dimension, brand, manager, status, search, date_start, date_end) hds = bool(params["date_start"]) hde = bool(params["date_end"]) @@ -185,6 +197,15 @@ async def meta(city: str = "", dimension: str = "", brand: str = "", manager: st params, ), ) + if role != "manager": + # Truncated tier: never expose client-facing facet values. + return { + "cities": [r["city"] for r in cities], + "dimensions": [r["dimension"] for r in dimensions], + "brands": [], + "managers": [], + "statuses": [], + } return { "cities": [r["city"] for r in cities], "dimensions": [r["dimension"] for r in dimensions], @@ -195,7 +216,11 @@ async def meta(city: str = "", dimension: str = "", brand: str = "", manager: st @app.get("/api/boards") -async def boards(city: str = "", dimension: str = "", brand: str = "", manager: str = "", status: str = "", search: str = "", date_start: str = "", date_end: str = ""): +async def boards(role: str = Depends(get_role), city: str = "", dimension: str = "", brand: str = "", manager: str = "", status: str = "", search: str = "", date_start: str = "", date_end: str = ""): + # Board inventory (address/city/dimension) is visible to everyone; anonymous + # just can't filter by the hidden facets. + if role != "manager": + brand = manager = status = "" params = parse_filters(city, dimension, brand, manager, status, search, date_start, date_end) where = build_where(city=True, dimension=True, brand=True, manager=True, status=True, search=True, date_start=bool(params["date_start"]), date_end=bool(params["date_end"])) sql = f""" @@ -214,7 +239,9 @@ async def boards(city: str = "", dimension: str = "", brand: str = "", manager: @app.get("/api/bookings") -async def bookings(city: str = "", dimension: str = "", brand: str = "", manager: str = "", status: str = "", search: str = "", date_start: str = "", date_end: str = ""): +async def bookings(role: str = Depends(get_role), city: str = "", dimension: str = "", brand: str = "", manager: str = "", status: str = "", search: str = "", date_start: str = "", date_end: str = ""): + if role != "manager": + brand = manager = status = "" params = parse_filters(city, dimension, brand, manager, status, search, date_start, date_end) where = build_where(city=True, dimension=True, brand=True, manager=True, status=True, search=True, date_start=bool(params["date_start"]), date_end=bool(params["date_end"])) sql = f""" @@ -236,11 +263,23 @@ async def bookings(city: str = "", dimension: str = "", brand: str = "", manager # collapse to canonical "Фамилия Имя" names for display. for r in rows: r["manager"] = sorted({canon_manager(m) for m in (r.get("manager") or []) if m}) + if role != "manager": + # Truncated tier: keep the occupancy bar (board_id + dates) but strip every + # client-facing field so the timeline shows neutral, unlabelled bars and the + # board panel / Planfix deep-links have nothing to reveal. + for r in rows: + r["brand"] = "" + r["company_name"] = "" + r["manager"] = [] + r["task_id"] = "" + r["task_status"] = "" return rows @app.get("/api/map") -async def map_data(city: str = "", dimension: str = "", brand: str = "", manager: str = "", status: str = "", search: str = ""): +async def map_data(role: str = Depends(get_role), city: str = "", dimension: str = "", brand: str = "", manager: str = "", status: str = "", search: str = ""): + if role != "manager": + brand = manager = status = "" # Surfaces come from board_info (has coordinates). Occupancy is computed "as of # today" and is intentionally NOT affected by the date-range filters — only the # Город/Бренд/Размер/Менеджер/Поиск facets narrow which surfaces are shown. @@ -292,13 +331,16 @@ async def map_data(city: str = "", dimension: str = "", brand: str = "", manager for b in current: by_key.setdefault(b["board_key"], []).append(b) + anon = role != "manager" out = [] for s in surfaces: active = by_key.get(s["board_key"], []) out.append({ **s, "occupied_now": len(active) > 0, - "bookings": active, + # Truncated tier keeps the free/occupied marker colour but hides the + # balloon's booking details (company/brand/status). + "bookings": [] if anon else active, }) return out diff --git a/requirements.txt b/requirements.txt index e5dd7aa..61352f1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ fastapi==0.115.0 uvicorn==0.30.6 httpx==0.27.2 +PyJWT[crypto]==2.10.1 diff --git a/static/silent-check-sso.html b/static/silent-check-sso.html new file mode 100644 index 0000000..6bb81c2 --- /dev/null +++ b/static/silent-check-sso.html @@ -0,0 +1,10 @@ + + + + + +