diff --git a/apps/web/src/lib/api-client.ts b/apps/web/src/lib/api-client.ts index 9bfbc3e..6d2ad0f 100644 --- a/apps/web/src/lib/api-client.ts +++ b/apps/web/src/lib/api-client.ts @@ -11,6 +11,9 @@ import type { export const TOKEN_STORAGE_KEY = 'evobgp_api_token' +/** Локальный demo-токен (operator) при включённом demo-seed — см. docs/access.md */ +export const DEV_API_TOKEN = 'dev' + export type Problem = { type?: string title?: string @@ -18,14 +21,31 @@ export type Problem = { detail?: string } +/** Убирает пробелы и опциональный префикс Bearer (UI часто вставляет «Bearer dev»). */ +export function normalizeApiToken(raw: string): string { + let t = raw.trim() + if (/^bearer\s+/i.test(t)) { + t = t.replace(/^bearer\s+/i, '').trim() + } + return t +} + function getToken(): string | null { if (typeof window === 'undefined') return null - return window.localStorage.getItem(TOKEN_STORAGE_KEY) + const raw = window.localStorage.getItem(TOKEN_STORAGE_KEY) + if (!raw) return null + const normalized = normalizeApiToken(raw) + return normalized || null } export function setToken(token: string | null): void { if (typeof window === 'undefined') return - if (token) window.localStorage.setItem(TOKEN_STORAGE_KEY, token) + if (!token) { + window.localStorage.removeItem(TOKEN_STORAGE_KEY) + return + } + const normalized = normalizeApiToken(token) + if (normalized) window.localStorage.setItem(TOKEN_STORAGE_KEY, normalized) else window.localStorage.removeItem(TOKEN_STORAGE_KEY) } diff --git a/apps/web/src/queries/settings.ts b/apps/web/src/queries/settings.ts index 52f1dd1..497091b 100644 --- a/apps/web/src/queries/settings.ts +++ b/apps/web/src/queries/settings.ts @@ -42,12 +42,14 @@ export const BOOLEAN_SETTING_KEYS = new Set(['runtime_logs_auto export const settingsKeys = { all: ['settings'] as const, + tenant: (tenantId: string) => [...settingsKeys.all, tenantId] as const, } -export function settingsQueryOptions() { +export function settingsQueryOptions(tenantId?: string | null) { return queryOptions({ - queryKey: settingsKeys.all, + queryKey: settingsKeys.tenant(tenantId ?? ''), queryFn: () => apiJSON('/v1/settings'), + enabled: Boolean(tenantId), staleTime: 30_000, }) } diff --git a/apps/web/src/routes/_auth.tsx b/apps/web/src/routes/_auth.tsx index 96ba1d2..4708472 100644 --- a/apps/web/src/routes/_auth.tsx +++ b/apps/web/src/routes/_auth.tsx @@ -1,10 +1,14 @@ import { createFileRoute, Outlet, redirect } from '@tanstack/react-router' +import { normalizeApiToken, TOKEN_STORAGE_KEY } from '@/lib/api-client' + export const Route = createFileRoute('/_auth')({ - beforeLoad: () => { - const token = - typeof window !== 'undefined' ? window.localStorage.getItem('evobgp_api_token') : null - if (!token) { + beforeLoad: ({ location }) => { + // Настройки доступны без токена — сюда попадают при первом входе (в т.ч. для `dev`). + if (location.pathname === '/settings') return + const raw = + typeof window !== 'undefined' ? window.localStorage.getItem(TOKEN_STORAGE_KEY) : null + if (!raw || !normalizeApiToken(raw)) { throw redirect({ to: '/settings' }) } }, diff --git a/apps/web/src/routes/_auth/access.tsx b/apps/web/src/routes/_auth/access.tsx index c084e72..a3b5361 100644 --- a/apps/web/src/routes/_auth/access.tsx +++ b/apps/web/src/routes/_auth/access.tsx @@ -86,11 +86,12 @@ function AccessComponent() { Роли: viewer (чтение),{' '} editor (CRUD), operator{' '} (apply и настройки), node (API ноды). Полный токен - показывается один раз при создании и ротации. Bearer для браузера — в{' '} + показывается один раз при создании и ротации. Токен браузера — в{' '} настройках - . + ; для локальной разработки с demo-seed подойдёт dev{' '} + (роль operator). @@ -114,11 +115,14 @@ function AccessComponent() { ) : ( - Не удалось определить сессию. Укажите Bearer-токен в{' '} + Не удалось определить сессию. Укажите токен в{' '} настройках {' '} - интерфейса. + (для dev-окружения — dev при включённом demo-seed). + {sessionQuery.isError && sessionQuery.error instanceof Error ? ( + {sessionQuery.error.message} + ) : null} )} diff --git a/apps/web/src/routes/_auth/settings.tsx b/apps/web/src/routes/_auth/settings.tsx index 50c5182..88cd603 100644 --- a/apps/web/src/routes/_auth/settings.tsx +++ b/apps/web/src/routes/_auth/settings.tsx @@ -1,6 +1,8 @@ -import { createFileRoute } from '@tanstack/react-router' -import { useQuery } from '@tanstack/react-query' +import { createFileRoute, useNavigate } from '@tanstack/react-router' +import { useQuery, useQueryClient } from '@tanstack/react-query' +import { Alert, AlertDescription, AlertTitle } from '@evobgp/ui/components/alert' +import { Button } from '@evobgp/ui/components/button' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@evobgp/ui/components/card' import { Input } from '@evobgp/ui/components/input' import { Label } from '@evobgp/ui/components/label' @@ -14,10 +16,10 @@ import { import { PageHeader } from '@/components/page-header' import { LoadingButton } from '@/components/loading-button' -import { setToken, TOKEN_STORAGE_KEY } from '@/lib/api-client' -import { authSessionQueryOptions } from '@/queries/auth' +import { DEV_API_TOKEN, normalizeApiToken, setToken, TOKEN_STORAGE_KEY } from '@/lib/api-client' +import { authKeys, authSessionQueryOptions } from '@/queries/auth' import { toast } from 'sonner' -import { Save } from 'lucide-react' +import { Info, Save } from 'lucide-react' import { useTheme } from 'next-themes' import { useEffect, useState } from 'react' @@ -32,7 +34,14 @@ const THEME_SELECT_ITEMS = [ ] as const function SettingsComponent() { - const { data: session } = useQuery(authSessionQueryOptions()) + const navigate = useNavigate() + const qc = useQueryClient() + const { data: session, isError: sessionError, error: sessionQueryError } = useQuery({ + ...authSessionQueryOptions(), + enabled: Boolean( + typeof window !== 'undefined' && window.localStorage.getItem(TOKEN_STORAGE_KEY)?.trim(), + ), + }) const { theme, setTheme } = useTheme() const [token, setTokenValue] = useState('') @@ -41,10 +50,23 @@ function SettingsComponent() { setTokenValue(t) }, []) - function saveTokenHandler() { - const t = token.trim() - setToken(t || null) + async function applyToken(raw: string) { + const normalized = normalizeApiToken(raw) + setToken(normalized || null) + setTokenValue(normalized) + await qc.invalidateQueries({ queryKey: authKeys.all }) toast.success('Токен сохранён') + if (normalized) { + void navigate({ to: '/dashboard' }) + } + } + + function saveTokenHandler() { + void applyToken(token) + } + + function useDevToken() { + void applyToken(DEV_API_TOKEN) } return ( @@ -54,11 +76,21 @@ function SettingsComponent() { description="Параметры интерфейса и подключения браузера к API." /> + + + Локальная разработка + + При включённом demo-seed API принимает токен dev (роль{' '} + operator). Вводите только значение токена, без префикса{' '} + Bearer — он добавляется автоматически. + + + Подключение к API - Bearer-токен хранится только в этом браузере (localStorage). Управление ключами tenant — в + Токен хранится только в этом браузере (localStorage). Управление ключами tenant — в разделе «Права доступа». @@ -71,19 +103,33 @@ function SettingsComponent() { autoComplete="off" value={token} onChange={(e) => setTokenValue(e.target.value)} - placeholder="Bearer …" + placeholder="dev или API-ключ" /> - - - Сохранить токен - +
+ + + Сохранить токен + + +
{session ? (

Активная сессия: tenant {session.tenant_id}, роль{' '} {session.role}.

) : null} + {sessionError ? ( +

+ {sessionQueryError instanceof Error + ? sessionQueryError.message + : 'Не удалось проверить сессию'} + . Для токена dev нужен demo-seed ( + EVOBGP_SEED_DEMO ≠ 0) и запущенный API. +

+ ) : null}
diff --git a/apps/web/src/routes/_auth/tenant-settings.tsx b/apps/web/src/routes/_auth/tenant-settings.tsx index 9265cda..49393b1 100644 --- a/apps/web/src/routes/_auth/tenant-settings.tsx +++ b/apps/web/src/routes/_auth/tenant-settings.tsx @@ -35,9 +35,11 @@ import { RUNTIME_LOGS_SETTING_KEYS, buildPayload, partitionSettings, + settingsKeys, settingsQueryOptions, type BirdSettingKey, } from '@/queries/settings' +import { authSessionQueryOptions } from '@/queries/auth' import { apiMutate } from '@/lib/api-client' export const Route = createFileRoute('/_auth/tenant-settings')({ @@ -70,7 +72,9 @@ const BIRD_LABELS: Record = { function TenantSettingsComponent() { const search = useSearch({ from: '/_auth/tenant-settings' }) - const settingsQ = useQuery(settingsQueryOptions()) + const sessionQ = useQuery(authSessionQueryOptions()) + const tenantId = sessionQ.data?.tenant_id ?? null + const settingsQ = useQuery(settingsQueryOptions(tenantId)) const qc = useQueryClient() const partitioned = settingsQ.data ? partitionSettings(settingsQ.data) : null @@ -93,7 +97,7 @@ function TenantSettingsComponent() { apiMutate('/v1/settings', 'PATCH', payload), onSuccess: () => { toast.success('Параметры сохранены') - void qc.invalidateQueries({ queryKey: ['settings'] }) + void qc.invalidateQueries({ queryKey: settingsKeys.all }) }, onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось сохранить'), }) diff --git a/docs/access.md b/docs/access.md index 3292788..29e2a30 100644 --- a/docs/access.md +++ b/docs/access.md @@ -50,6 +50,8 @@ opkey|01ARZ3NDEKTSV4RRFFQ69G5FAV|operator,nodekey|01ARZ3NDEKTSV4RRFFQ69G5FAV|nod Если в store доступен демо-tenant (`DemoIDs`, обычно `EVOBGP_SEED_DEMO` не равен `0`), заголовок **`Authorization: Bearer dev`** даёт роль **`operator`** для этого tenant. **Не зависит** от `EVOBGP_DEV_INSECURE`. +Если токен `dev` также задан в `EVOBGP_API_KEYS` или таблице `api_key`, **приоритет у явной записи** (production tenant), а не у demo-shortcut. + **Запрещено** в продакшене: не оставляйте demo-seed с известным токеном `dev` на боевых данных. Переменная `EVOBGP_DEV_INSECURE` в текущей версии **не влияет** на аутентификацию (оставлена в compose для совместимости; не включайте в production — см. SEC-02 в инженерных правилах). ### PostgreSQL monitoring и maintenance (control plane) diff --git a/internal/httpapi/auth.go b/internal/httpapi/auth.go index 00b5abd..be57402 100644 --- a/internal/httpapi/auth.go +++ b/internal/httpapi/auth.go @@ -63,27 +63,42 @@ func (s *Server) authMiddleware(next http.Handler) http.Handler { return } raw := strings.TrimSpace(strings.TrimPrefix(h, p)) - if raw == "dev" { - if a, ok := s.devAuth(); ok { - r = r.WithContext(context.WithValue(r.Context(), authCtxKey, a)) - next.ServeHTTP(w, r) - return - } - } - matched, ok := s.keyResolver.Lookup(raw) + a, ok := s.resolveAuth(raw) if !ok { writeProblem(w, http.StatusUnauthorized, "Unauthorized", "unknown api key") return } - a := Auth{TenantID: matched.tenantID, Role: matched.role, Token: raw, APIKeyID: matched.keyID} - if matched.keyID != "" { - go func(id string) { _ = s.store.TouchAPIKeyLastUsed(id) }(matched.keyID) + if a.APIKeyID != "" { + go func(id string) { _ = s.store.TouchAPIKeyLastUsed(id) }(a.APIKeyID) } r = r.WithContext(context.WithValue(r.Context(), authCtxKey, a)) next.ServeHTTP(w, r) }) } +func authFromKeyRecord(raw string, rec apiKeyRecord) Auth { + return Auth{TenantID: rec.tenantID, Role: rec.role, Token: raw, APIKeyID: rec.keyID} +} + +// resolveAuth maps a bearer token to tenant identity. +// For the literal token "dev", env/DB keys take precedence over the demo shortcut (devAuth). +func (s *Server) resolveAuth(raw string) (Auth, bool) { + if raw == "dev" { + if rec, ok := s.keyResolver.Lookup(raw); ok { + return authFromKeyRecord(raw, rec), true + } + if a, ok := s.devAuth(); ok { + return a, true + } + return Auth{}, false + } + rec, ok := s.keyResolver.Lookup(raw) + if !ok { + return Auth{}, false + } + return authFromKeyRecord(raw, rec), true +} + func (s *Server) devAuth() (Auth, bool) { tid, _, _, _, _ := s.store.DemoIDs() if tid == "" { diff --git a/internal/httpapi/auth_dev_settings_test.go b/internal/httpapi/auth_dev_settings_test.go new file mode 100644 index 0000000..a8073d5 --- /dev/null +++ b/internal/httpapi/auth_dev_settings_test.go @@ -0,0 +1,67 @@ +package httpapi + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" +) + +func TestBearerDevGetSettings(t *testing.T) { + srv, err := New(Options{SeedDemo: true, BundleSeedHex: testBundleSeed}) + if err != nil { + t.Fatal(err) + } + defer srv.Close() + + ts := httptest.NewServer(srv.Handler()) + defer ts.Close() + + req, _ := http.NewRequest(http.MethodGet, ts.URL+"/v1/settings", nil) + req.Header.Set("Authorization", "Bearer dev") + resp, err := ts.Client().Do(req) + if err != nil { + t.Fatal(err) + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusOK { + b, _ := io.ReadAll(resp.Body) + t.Fatalf("status=%d body=%s", resp.StatusCode, b) + } +} + +func TestBearerDevPrefersEnvAPIKeyOverDemoTenant(t *testing.T) { + srv, err := New(Options{SeedDemo: true, BundleSeedHex: testBundleSeed}) + if err != nil { + t.Fatal(err) + } + defer srv.Close() + + demoTenant, _, _, _, _ := srv.Store().DemoIDs() + otherTenant := "00000000-0000-4000-8000-000000000001" + mustSetTestAPIKeys(t, srv, "dev|"+otherTenant+"|operator") + + ts := httptest.NewServer(srv.Handler()) + defer ts.Close() + + req, _ := http.NewRequest(http.MethodGet, ts.URL+"/v1/auth/session", nil) + req.Header.Set("Authorization", "Bearer dev") + resp, err := ts.Client().Do(req) + if err != nil { + t.Fatal(err) + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusOK { + b, _ := io.ReadAll(resp.Body) + t.Fatalf("session status=%d body=%s", resp.StatusCode, b) + } + var body map[string]any + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { + t.Fatal(err) + } + got, _ := body["tenant_id"].(string) + if got != otherTenant { + t.Fatalf("tenant_id=%q want env key tenant %q (demo=%q)", got, otherTenant, demoTenant) + } +} diff --git a/internal/repository/postgres_entities.go b/internal/repository/postgres_entities.go index 10d74e9..dce0db3 100644 --- a/internal/repository/postgres_entities.go +++ b/internal/repository/postgres_entities.go @@ -589,6 +589,9 @@ func (p *Postgres) DeleteIPRangeEntry(tenantID, moduleID, entryID string) error } func (p *Postgres) ListGlobalSettings(tenantID string) (map[string]any, error) { + if _, err := uuid.Parse(tenantID); err != nil { + return nil, store.ErrInvalidInput + } ctx := context.Background() rows, err := p.pool.Query(ctx, `SELECT key, value_json FROM global_settings WHERE tenant_id=$1`, tenantID) if err != nil { @@ -610,6 +613,9 @@ func (p *Postgres) ListGlobalSettings(tenantID string) (map[string]any, error) { } func (p *Postgres) PatchGlobalSettings(tenantID string, patch map[string]any) error { + if _, err := uuid.Parse(tenantID); err != nil { + return store.ErrInvalidInput + } if patch == nil { return nil }