Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
db79820df0 |
@@ -11,6 +11,9 @@ import type {
|
|||||||
|
|
||||||
export const TOKEN_STORAGE_KEY = 'evobgp_api_token'
|
export const TOKEN_STORAGE_KEY = 'evobgp_api_token'
|
||||||
|
|
||||||
|
/** Локальный demo-токен (operator) при включённом demo-seed — см. docs/access.md */
|
||||||
|
export const DEV_API_TOKEN = 'dev'
|
||||||
|
|
||||||
export type Problem = {
|
export type Problem = {
|
||||||
type?: string
|
type?: string
|
||||||
title?: string
|
title?: string
|
||||||
@@ -18,14 +21,31 @@ export type Problem = {
|
|||||||
detail?: string
|
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 {
|
function getToken(): string | null {
|
||||||
if (typeof window === 'undefined') return 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 {
|
export function setToken(token: string | null): void {
|
||||||
if (typeof window === 'undefined') return
|
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)
|
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 = {
|
export const settingsKeys = {
|
||||||
all: ['settings'] as const,
|
all: ['settings'] as const,
|
||||||
|
tenant: (tenantId: string) => [...settingsKeys.all, tenantId] as const,
|
||||||
}
|
}
|
||||||
|
|
||||||
export function settingsQueryOptions() {
|
export function settingsQueryOptions(tenantId?: string | null) {
|
||||||
return queryOptions<AppSettings>({
|
return queryOptions<AppSettings>({
|
||||||
queryKey: settingsKeys.all,
|
queryKey: settingsKeys.tenant(tenantId ?? ''),
|
||||||
queryFn: () => apiJSON<AppSettings>('/v1/settings'),
|
queryFn: () => apiJSON<AppSettings>('/v1/settings'),
|
||||||
|
enabled: Boolean(tenantId),
|
||||||
staleTime: 30_000,
|
staleTime: 30_000,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
import { createFileRoute, Outlet, redirect } from '@tanstack/react-router'
|
import { createFileRoute, Outlet, redirect } from '@tanstack/react-router'
|
||||||
|
|
||||||
|
import { normalizeApiToken, TOKEN_STORAGE_KEY } from '@/lib/api-client'
|
||||||
|
|
||||||
export const Route = createFileRoute('/_auth')({
|
export const Route = createFileRoute('/_auth')({
|
||||||
beforeLoad: () => {
|
beforeLoad: ({ location }) => {
|
||||||
const token =
|
// Настройки доступны без токена — сюда попадают при первом входе (в т.ч. для `dev`).
|
||||||
typeof window !== 'undefined' ? window.localStorage.getItem('evobgp_api_token') : null
|
if (location.pathname === '/settings') return
|
||||||
if (!token) {
|
const raw =
|
||||||
|
typeof window !== 'undefined' ? window.localStorage.getItem(TOKEN_STORAGE_KEY) : null
|
||||||
|
if (!raw || !normalizeApiToken(raw)) {
|
||||||
throw redirect({ to: '/settings' })
|
throw redirect({ to: '/settings' })
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -86,11 +86,12 @@ function AccessComponent() {
|
|||||||
Роли: <code className="text-xs">viewer</code> (чтение),{' '}
|
Роли: <code className="text-xs">viewer</code> (чтение),{' '}
|
||||||
<code className="text-xs">editor</code> (CRUD), <code className="text-xs">operator</code>{' '}
|
<code className="text-xs">editor</code> (CRUD), <code className="text-xs">operator</code>{' '}
|
||||||
(apply и настройки), <code className="text-xs">node</code> (API ноды). Полный токен
|
(apply и настройки), <code className="text-xs">node</code> (API ноды). Полный токен
|
||||||
показывается один раз при создании и ротации. Bearer для браузера — в{' '}
|
показывается один раз при создании и ротации. Токен браузера — в{' '}
|
||||||
<Link to="/settings" className="text-primary underline-offset-4 hover:underline">
|
<Link to="/settings" className="text-primary underline-offset-4 hover:underline">
|
||||||
настройках
|
настройках
|
||||||
</Link>
|
</Link>
|
||||||
.
|
; для локальной разработки с demo-seed подойдёт <code className="text-xs">dev</code>{' '}
|
||||||
|
(роль operator).
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
|
|
||||||
@@ -114,11 +115,14 @@ function AccessComponent() {
|
|||||||
) : (
|
) : (
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="py-6 text-sm text-muted-foreground">
|
<CardContent className="py-6 text-sm text-muted-foreground">
|
||||||
Не удалось определить сессию. Укажите Bearer-токен в{' '}
|
Не удалось определить сессию. Укажите токен в{' '}
|
||||||
<Link to="/settings" className="text-primary underline-offset-4 hover:underline">
|
<Link to="/settings" className="text-primary underline-offset-4 hover:underline">
|
||||||
настройках
|
настройках
|
||||||
</Link>{' '}
|
</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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { createFileRoute } from '@tanstack/react-router'
|
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
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 { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@evobgp/ui/components/card'
|
||||||
import { Input } from '@evobgp/ui/components/input'
|
import { Input } from '@evobgp/ui/components/input'
|
||||||
import { Label } from '@evobgp/ui/components/label'
|
import { Label } from '@evobgp/ui/components/label'
|
||||||
@@ -14,10 +16,10 @@ import {
|
|||||||
|
|
||||||
import { PageHeader } from '@/components/page-header'
|
import { PageHeader } from '@/components/page-header'
|
||||||
import { LoadingButton } from '@/components/loading-button'
|
import { LoadingButton } from '@/components/loading-button'
|
||||||
import { setToken, TOKEN_STORAGE_KEY } from '@/lib/api-client'
|
import { DEV_API_TOKEN, normalizeApiToken, setToken, TOKEN_STORAGE_KEY } from '@/lib/api-client'
|
||||||
import { authSessionQueryOptions } from '@/queries/auth'
|
import { authKeys, authSessionQueryOptions } from '@/queries/auth'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { Save } from 'lucide-react'
|
import { Info, Save } from 'lucide-react'
|
||||||
import { useTheme } from 'next-themes'
|
import { useTheme } from 'next-themes'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
|
|
||||||
@@ -32,7 +34,14 @@ const THEME_SELECT_ITEMS = [
|
|||||||
] as const
|
] as const
|
||||||
|
|
||||||
function SettingsComponent() {
|
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 { theme, setTheme } = useTheme()
|
||||||
const [token, setTokenValue] = useState('')
|
const [token, setTokenValue] = useState('')
|
||||||
|
|
||||||
@@ -41,10 +50,23 @@ function SettingsComponent() {
|
|||||||
setTokenValue(t)
|
setTokenValue(t)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
function saveTokenHandler() {
|
async function applyToken(raw: string) {
|
||||||
const t = token.trim()
|
const normalized = normalizeApiToken(raw)
|
||||||
setToken(t || null)
|
setToken(normalized || null)
|
||||||
|
setTokenValue(normalized)
|
||||||
|
await qc.invalidateQueries({ queryKey: authKeys.all })
|
||||||
toast.success('Токен сохранён')
|
toast.success('Токен сохранён')
|
||||||
|
if (normalized) {
|
||||||
|
void navigate({ to: '/dashboard' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveTokenHandler() {
|
||||||
|
void applyToken(token)
|
||||||
|
}
|
||||||
|
|
||||||
|
function useDevToken() {
|
||||||
|
void applyToken(DEV_API_TOKEN)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -54,11 +76,21 @@ function SettingsComponent() {
|
|||||||
description="Параметры интерфейса и подключения браузера к API."
|
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>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Подключение к API</CardTitle>
|
<CardTitle>Подключение к API</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Bearer-токен хранится только в этом браузере (localStorage). Управление ключами tenant — в
|
Токен хранится только в этом браузере (localStorage). Управление ключами tenant — в
|
||||||
разделе «Права доступа».
|
разделе «Права доступа».
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
@@ -71,19 +103,33 @@ function SettingsComponent() {
|
|||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
value={token}
|
value={token}
|
||||||
onChange={(e) => setTokenValue(e.target.value)}
|
onChange={(e) => setTokenValue(e.target.value)}
|
||||||
placeholder="Bearer …"
|
placeholder="dev или API-ключ"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<LoadingButton onClick={saveTokenHandler}>
|
<div className="flex flex-wrap gap-2">
|
||||||
<Save />
|
<LoadingButton onClick={saveTokenHandler}>
|
||||||
Сохранить токен
|
<Save />
|
||||||
</LoadingButton>
|
Сохранить токен
|
||||||
|
</LoadingButton>
|
||||||
|
<Button type="button" variant="outline" onClick={useDevToken}>
|
||||||
|
Использовать dev
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
{session ? (
|
{session ? (
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Активная сессия: tenant <code className="font-mono">{session.tenant_id}</code>, роль{' '}
|
Активная сессия: tenant <code className="font-mono">{session.tenant_id}</code>, роль{' '}
|
||||||
<code className="font-mono">{session.role}</code>.
|
<code className="font-mono">{session.role}</code>.
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : 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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|||||||
@@ -35,9 +35,11 @@ import {
|
|||||||
RUNTIME_LOGS_SETTING_KEYS,
|
RUNTIME_LOGS_SETTING_KEYS,
|
||||||
buildPayload,
|
buildPayload,
|
||||||
partitionSettings,
|
partitionSettings,
|
||||||
|
settingsKeys,
|
||||||
settingsQueryOptions,
|
settingsQueryOptions,
|
||||||
type BirdSettingKey,
|
type BirdSettingKey,
|
||||||
} from '@/queries/settings'
|
} from '@/queries/settings'
|
||||||
|
import { authSessionQueryOptions } from '@/queries/auth'
|
||||||
import { apiMutate } from '@/lib/api-client'
|
import { apiMutate } from '@/lib/api-client'
|
||||||
|
|
||||||
export const Route = createFileRoute('/_auth/tenant-settings')({
|
export const Route = createFileRoute('/_auth/tenant-settings')({
|
||||||
@@ -70,7 +72,9 @@ const BIRD_LABELS: Record<BirdSettingKey, string> = {
|
|||||||
|
|
||||||
function TenantSettingsComponent() {
|
function TenantSettingsComponent() {
|
||||||
const search = useSearch({ from: '/_auth/tenant-settings' })
|
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 qc = useQueryClient()
|
||||||
|
|
||||||
const partitioned = settingsQ.data ? partitionSettings(settingsQ.data) : null
|
const partitioned = settingsQ.data ? partitionSettings(settingsQ.data) : null
|
||||||
@@ -93,7 +97,7 @@ function TenantSettingsComponent() {
|
|||||||
apiMutate('/v1/settings', 'PATCH', payload),
|
apiMutate('/v1/settings', 'PATCH', payload),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success('Параметры сохранены')
|
toast.success('Параметры сохранены')
|
||||||
void qc.invalidateQueries({ queryKey: ['settings'] })
|
void qc.invalidateQueries({ queryKey: settingsKeys.all })
|
||||||
},
|
},
|
||||||
onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось сохранить'),
|
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`.
|
Если в 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 в инженерных правилах).
|
**Запрещено** в продакшене: не оставляйте demo-seed с известным токеном `dev` на боевых данных. Переменная `EVOBGP_DEV_INSECURE` в текущей версии **не влияет** на аутентификацию (оставлена в compose для совместимости; не включайте в production — см. SEC-02 в инженерных правилах).
|
||||||
|
|
||||||
### PostgreSQL monitoring и maintenance (control plane)
|
### PostgreSQL monitoring и maintenance (control plane)
|
||||||
|
|||||||
+26
-11
@@ -63,27 +63,42 @@ func (s *Server) authMiddleware(next http.Handler) http.Handler {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
raw := strings.TrimSpace(strings.TrimPrefix(h, p))
|
raw := strings.TrimSpace(strings.TrimPrefix(h, p))
|
||||||
if raw == "dev" {
|
a, ok := s.resolveAuth(raw)
|
||||||
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)
|
|
||||||
if !ok {
|
if !ok {
|
||||||
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "unknown api key")
|
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "unknown api key")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
a := Auth{TenantID: matched.tenantID, Role: matched.role, Token: raw, APIKeyID: matched.keyID}
|
if a.APIKeyID != "" {
|
||||||
if matched.keyID != "" {
|
go func(id string) { _ = s.store.TouchAPIKeyLastUsed(id) }(a.APIKeyID)
|
||||||
go func(id string) { _ = s.store.TouchAPIKeyLastUsed(id) }(matched.keyID)
|
|
||||||
}
|
}
|
||||||
r = r.WithContext(context.WithValue(r.Context(), authCtxKey, a))
|
r = r.WithContext(context.WithValue(r.Context(), authCtxKey, a))
|
||||||
next.ServeHTTP(w, r)
|
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) {
|
func (s *Server) devAuth() (Auth, bool) {
|
||||||
tid, _, _, _, _ := s.store.DemoIDs()
|
tid, _, _, _, _ := s.store.DemoIDs()
|
||||||
if tid == "" {
|
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) {
|
func (p *Postgres) ListGlobalSettings(tenantID string) (map[string]any, error) {
|
||||||
|
if _, err := uuid.Parse(tenantID); err != nil {
|
||||||
|
return nil, store.ErrInvalidInput
|
||||||
|
}
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
rows, err := p.pool.Query(ctx, `SELECT key, value_json FROM global_settings WHERE tenant_id=$1`, tenantID)
|
rows, err := p.pool.Query(ctx, `SELECT key, value_json FROM global_settings WHERE tenant_id=$1`, tenantID)
|
||||||
if err != nil {
|
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 {
|
func (p *Postgres) PatchGlobalSettings(tenantID string, patch map[string]any) error {
|
||||||
|
if _, err := uuid.Parse(tenantID); err != nil {
|
||||||
|
return store.ErrInvalidInput
|
||||||
|
}
|
||||||
if patch == nil {
|
if patch == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user