Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
db79820df0 |
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -42,12 +42,14 @@ export const BOOLEAN_SETTING_KEYS = new Set<KnownSettingKey>(['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<AppSettings>({
|
||||
queryKey: settingsKeys.all,
|
||||
queryKey: settingsKeys.tenant(tenantId ?? ''),
|
||||
queryFn: () => apiJSON<AppSettings>('/v1/settings'),
|
||||
enabled: Boolean(tenantId),
|
||||
staleTime: 30_000,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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' })
|
||||
}
|
||||
},
|
||||
|
||||
@@ -86,11 +86,12 @@ function AccessComponent() {
|
||||
Роли: <code className="text-xs">viewer</code> (чтение),{' '}
|
||||
<code className="text-xs">editor</code> (CRUD), <code className="text-xs">operator</code>{' '}
|
||||
(apply и настройки), <code className="text-xs">node</code> (API ноды). Полный токен
|
||||
показывается один раз при создании и ротации. Bearer для браузера — в{' '}
|
||||
показывается один раз при создании и ротации. Токен браузера — в{' '}
|
||||
<Link to="/settings" className="text-primary underline-offset-4 hover:underline">
|
||||
настройках
|
||||
</Link>
|
||||
.
|
||||
; для локальной разработки с demo-seed подойдёт <code className="text-xs">dev</code>{' '}
|
||||
(роль operator).
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
@@ -114,11 +115,14 @@ function AccessComponent() {
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="py-6 text-sm text-muted-foreground">
|
||||
Не удалось определить сессию. Укажите Bearer-токен в{' '}
|
||||
Не удалось определить сессию. Укажите токен в{' '}
|
||||
<Link to="/settings" className="text-primary underline-offset-4 hover:underline">
|
||||
настройках
|
||||
</Link>{' '}
|
||||
интерфейса.
|
||||
(для dev-окружения — <code className="text-xs">dev</code> при включённом demo-seed).
|
||||
{sessionQuery.isError && sessionQuery.error instanceof Error ? (
|
||||
<span className="mt-2 block text-destructive">{sessionQuery.error.message}</span>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
@@ -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."
|
||||
/>
|
||||
|
||||
<Alert className="border-info/30 bg-info/5">
|
||||
<Info className="text-info" />
|
||||
<AlertTitle>Локальная разработка</AlertTitle>
|
||||
<AlertDescription>
|
||||
При включённом demo-seed API принимает токен <code className="text-xs">dev</code> (роль{' '}
|
||||
<code className="text-xs">operator</code>). Вводите только значение токена, без префикса{' '}
|
||||
<code className="text-xs">Bearer</code> — он добавляется автоматически.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Подключение к API</CardTitle>
|
||||
<CardDescription>
|
||||
Bearer-токен хранится только в этом браузере (localStorage). Управление ключами tenant — в
|
||||
Токен хранится только в этом браузере (localStorage). Управление ключами tenant — в
|
||||
разделе «Права доступа».
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
@@ -71,19 +103,33 @@ function SettingsComponent() {
|
||||
autoComplete="off"
|
||||
value={token}
|
||||
onChange={(e) => setTokenValue(e.target.value)}
|
||||
placeholder="Bearer …"
|
||||
placeholder="dev или API-ключ"
|
||||
/>
|
||||
</div>
|
||||
<LoadingButton onClick={saveTokenHandler}>
|
||||
<Save />
|
||||
Сохранить токен
|
||||
</LoadingButton>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<LoadingButton onClick={saveTokenHandler}>
|
||||
<Save />
|
||||
Сохранить токен
|
||||
</LoadingButton>
|
||||
<Button type="button" variant="outline" onClick={useDevToken}>
|
||||
Использовать dev
|
||||
</Button>
|
||||
</div>
|
||||
{session ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Активная сессия: tenant <code className="font-mono">{session.tenant_id}</code>, роль{' '}
|
||||
<code className="font-mono">{session.role}</code>.
|
||||
</p>
|
||||
) : null}
|
||||
{sessionError ? (
|
||||
<p className="text-xs text-destructive">
|
||||
{sessionQueryError instanceof Error
|
||||
? sessionQueryError.message
|
||||
: 'Не удалось проверить сессию'}
|
||||
. Для токена <code className="font-mono">dev</code> нужен demo-seed (
|
||||
<code className="text-xs">EVOBGP_SEED_DEMO</code> ≠ 0) и запущенный API.
|
||||
</p>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
|
||||
@@ -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<BirdSettingKey, string> = {
|
||||
|
||||
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 : 'Не удалось сохранить'),
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
+26
-11
@@ -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 == "" {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user