auth: Keycloak OIDC — anonymous (truncated) vs manager (full) tiers

Backend (main.py + auth.py):
- Validate Bearer JWT against the intense-sale realm JWKS (RS256, issuer
  checked, aud skipped). Missing/invalid token = anonymous, never an error,
  so the public map keeps working when Keycloak is down.
- Anonymous truncation: /api/meta hides brand/manager/status facets;
  /api/bookings blanks brand/company_name/manager/task_id/task_status
  (bars stay, one neutral colour); /api/map keeps occupied_now but empties
  booking details. Hidden facet filters are ignored for anonymous callers.
- boards stay visible to all (physical inventory: address/city/dimension).

Frontend (keycloak-js):
- Silent SSO (check-sso) — anonymous by default, Войти/Выйти button.
- Bearer token attached + refreshed on every API call.
- Anonymous UI hides Brand/Manager/Status filters, Brand/Company toggles,
  appearance menu, collisions and labels; board detail panel (Planfix
  deep-links) gated to managers.

Test manager: user / 1234567890 (realm role `manager`).
This commit is contained in:
aaverbitskiy 2026-08-14 11:05:47 +00:00
parent 4425bd394c
commit b6b9c27cb0
13 changed files with 263 additions and 7 deletions

View File

@ -3,6 +3,7 @@ WORKDIR /app
COPY requirements.txt . COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt RUN pip install --no-cache-dir -r requirements.txt
COPY main.py . COPY main.py .
COPY auth.py .
COPY static ./static COPY static ./static
EXPOSE 8000 EXPOSE 8000
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"] CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]

59
auth.py Normal file
View File

@ -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"

View File

@ -33,6 +33,7 @@
<button type="button" class="mode-btn" id="mode-split">Карта и график</button> <button type="button" class="mode-btn" id="mode-split">Карта и график</button>
</div> </div>
<button type="button" class="theme-toggle" id="theme-toggle" title="Переключить тему" aria-label="Переключить тему"></button> <button type="button" class="theme-toggle" id="theme-toggle" title="Переключить тему" aria-label="Переключить тему"></button>
<button type="button" class="auth-btn" id="auth-btn" style="display:none">Войти</button>
</div> </div>
<!-- Group 2: fields (4-column grid; dates + search on the 2nd row) --> <!-- Group 2: fields (4-column grid; dates + search on the 2nd row) -->

View File

@ -9,6 +9,7 @@
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@fontsource/ibm-plex-sans": "^5.3.0", "@fontsource/ibm-plex-sans": "^5.3.0",
"keycloak-js": "^26.2.4",
"open-props": "^1.7.23", "open-props": "^1.7.23",
"vis-data": "^7.1.9", "vis-data": "^7.1.9",
"vis-timeline": "^7.7.3" "vis-timeline": "^7.7.3"
@ -880,6 +881,15 @@
"license": "(Apache-2.0 OR MIT)", "license": "(Apache-2.0 OR MIT)",
"peer": true "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": { "node_modules/moment": {
"version": "2.30.1", "version": "2.30.1",
"resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz", "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz",

View File

@ -10,6 +10,7 @@
}, },
"dependencies": { "dependencies": {
"@fontsource/ibm-plex-sans": "^5.3.0", "@fontsource/ibm-plex-sans": "^5.3.0",
"keycloak-js": "^26.2.4",
"open-props": "^1.7.23", "open-props": "^1.7.23",
"vis-data": "^7.1.9", "vis-data": "^7.1.9",
"vis-timeline": "^7.7.3" "vis-timeline": "^7.7.3"

View File

@ -0,0 +1,10 @@
<!doctype html>
<html>
<body>
<script>
// Keycloak silent SSO check: hand the redirect URL back to the app in the
// parent window so keycloak-js can complete the check without a full reload.
parent.postMessage(location.href, location.origin);
</script>
</body>
</html>

View File

@ -1,6 +1,7 @@
// Typed wrappers around the mapdash HTTP API. All network access for the app // Typed wrappers around the mapdash HTTP API. All network access for the app
// goes through here, so response shapes stay in one typed place. // goes through here, so response shapes stay in one typed place.
import type { Meta, Board, Booking, Filters, MapSurface } from './types'; import type { Meta, Board, Booking, Filters, MapSurface } from './types';
import { authHeader } from './auth';
function buildQuery(f: Filters): string { function buildQuery(f: Filters): string {
const params = new URLSearchParams({ const params = new URLSearchParams({
@ -17,7 +18,7 @@ function buildQuery(f: Filters): string {
} }
async function getJson<T>(url: string): Promise<T> { async function getJson<T>(url: string): Promise<T> {
const res = await fetch(url); const res = await fetch(url, { headers: await authHeader() });
if (!res.ok) { if (!res.ok) {
throw new Error(`Request failed: ${url} -> HTTP ${res.status}`); throw new Error(`Request failed: ${url} -> HTTP ${res.status}`);
} }

69
frontend/src/auth.ts Normal file
View File

@ -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<void> {
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<Record<string, string>> {
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}` } : {};
}

View File

@ -19,6 +19,7 @@ import { loadAppearance, applyCssVars, buildAppearanceMenu, colorForStatus } fro
import { createMapView } from './map'; import { createMapView } from './map';
import { createViewModes, type ViewMode } from './viewmode'; import { createViewModes, type ViewMode } from './viewmode';
import { escapeHtml } from './util'; import { escapeHtml } from './util';
import { initAuth, isManager, isAuthenticated, login, logout } from './auth';
const el = { const el = {
search: document.getElementById('search') as HTMLInputElement, search: document.getElementById('search') as HTMLInputElement,
@ -50,8 +51,35 @@ const el = {
loadBar: document.getElementById('load-bar') as HTMLElement, loadBar: document.getElementById('load-bar') as HTMLElement,
emptyResetTimeline: document.getElementById('empty-reset-timeline') as HTMLButtonElement, emptyResetTimeline: document.getElementById('empty-reset-timeline') as HTMLButtonElement,
emptyResetMap: document.getElementById('empty-reset-map') 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) ---- // ---- theme toggle (data-theme is set pre-paint by the inline head script) ----
function currentTheme(): 'light' | 'dark' { function currentTheme(): 'light' | 'dark' {
return document.documentElement.getAttribute('data-theme') === 'dark' ? 'dark' : 'light'; return document.documentElement.getAttribute('data-theme') === 'dark' ? 'dark' : 'light';
@ -99,6 +127,11 @@ let lastBoards: Board[] = [];
let lastBookings: Booking[] = []; let lastBookings: Booking[] = [];
function flags(): RenderFlags { 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 { return {
withBrand: el.showBrand.checked, withBrand: el.showBrand.checked,
withCompany: el.showCompany.checked, withCompany: el.showCompany.checked,
@ -363,6 +396,8 @@ function closeBoardPanel(): void {
boardBackdrop.hidden = true; boardBackdrop.hidden = true;
} }
function openBoardPanel(boardId: string): void { 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 board = lastBoards.find((b) => b.board_id === boardId);
const bookings = lastBookings const bookings = lastBookings
.filter((b) => b.board_id === boardId) .filter((b) => b.board_id === boardId)
@ -410,7 +445,14 @@ async function init(): Promise<void> {
dimensionDropdown.setValues([], 'Все размеры'); dimensionDropdown.setValues([], 'Все размеры');
managerDropdown.setValues([], 'Все менеджеры'); managerDropdown.setValues([], 'Все менеджеры');
statusDropdown.setValues([], 'Все статусы'); statusDropdown.setValues([], 'Все статусы');
await initAuth(); // resolve role before first data load (degrades to anon on failure)
applyRoleUi();
applyUrlToFilters(); // restore filters from a shared link 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(); rebuildDecorMenu();
view.applyLayout(); view.applyLayout();
await loadData(true); await loadData(true);

View File

@ -312,6 +312,15 @@ input::placeholder { color: var(--text-muted); }
transition: border-color var(--transition), background var(--transition); transition: border-color var(--transition), background var(--transition);
} }
.theme-toggle:hover { border-color: var(--border-strong); background: var(--surface); } .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. /* 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. */ Re-enable by removing this rule + restoring the head script in index.html. */
.theme-toggle { display: none; } .theme-toggle { display: none; }

54
main.py
View File

@ -1,10 +1,18 @@
import asyncio import asyncio
import os import os
import httpx import httpx
from fastapi import FastAPI, Query from fastapi import Depends, FastAPI, Header, Query
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles 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_HOST = os.environ.get("CLICKHOUSE_HOST", "ClickHouse")
CH_PORT = os.environ.get("CLICKHOUSE_PORT", "8123") CH_PORT = os.environ.get("CLICKHOUSE_PORT", "8123")
CH_USER = os.environ.get("CLICKHOUSE_USER", "default") 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") @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) params = parse_filters(city, dimension, brand, manager, status, search, date_start, date_end)
hds = bool(params["date_start"]) hds = bool(params["date_start"])
hde = bool(params["date_end"]) hde = bool(params["date_end"])
@ -185,6 +197,15 @@ async def meta(city: str = "", dimension: str = "", brand: str = "", manager: st
params, 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 { return {
"cities": [r["city"] for r in cities], "cities": [r["city"] for r in cities],
"dimensions": [r["dimension"] for r in dimensions], "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") @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) 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"])) 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""" sql = f"""
@ -214,7 +239,9 @@ async def boards(city: str = "", dimension: str = "", brand: str = "", manager:
@app.get("/api/bookings") @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) 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"])) 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""" sql = f"""
@ -236,11 +263,23 @@ async def bookings(city: str = "", dimension: str = "", brand: str = "", manager
# collapse to canonical "Фамилия Имя" names for display. # collapse to canonical "Фамилия Имя" names for display.
for r in rows: for r in rows:
r["manager"] = sorted({canon_manager(m) for m in (r.get("manager") or []) if m}) 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 return rows
@app.get("/api/map") @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 # 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 # today" and is intentionally NOT affected by the date-range filters — only the
# Город/Бренд/Размер/Менеджер/Поиск facets narrow which surfaces are shown. # Город/Бренд/Размер/Менеджер/Поиск 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: for b in current:
by_key.setdefault(b["board_key"], []).append(b) by_key.setdefault(b["board_key"], []).append(b)
anon = role != "manager"
out = [] out = []
for s in surfaces: for s in surfaces:
active = by_key.get(s["board_key"], []) active = by_key.get(s["board_key"], [])
out.append({ out.append({
**s, **s,
"occupied_now": len(active) > 0, "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 return out

View File

@ -1,3 +1,4 @@
fastapi==0.115.0 fastapi==0.115.0
uvicorn==0.30.6 uvicorn==0.30.6
httpx==0.27.2 httpx==0.27.2
PyJWT[crypto]==2.10.1

View File

@ -0,0 +1,10 @@
<!doctype html>
<html>
<body>
<script>
// Keycloak silent SSO check: hand the redirect URL back to the app in the
// parent window so keycloak-js can complete the check without a full reload.
parent.postMessage(location.href, location.origin);
</script>
</body>
</html>