feat(auth): introduce demo token support and enhance API token handling
CI / changes (push) Successful in 10s
CI / commitlint (push) Has been skipped
CI / openapi (push) Successful in 28s
CI / web (push) Successful in 57s
CI / go (push) Successful in 1m9s
CI / bird2 (push) Successful in 17s
CI / release (push) Successful in 3m57s
CI / changes (push) Successful in 10s
CI / commitlint (push) Has been skipped
CI / openapi (push) Successful in 28s
CI / web (push) Successful in 57s
CI / go (push) Successful in 1m9s
CI / bird2 (push) Successful in 17s
CI / release (push) Successful in 3m57s
Added a local demo token for development purposes and improved the API token management by normalizing input tokens. Updated the authentication flow to utilize the new token handling, allowing for better session management and user experience. Enhanced the settings component to support the demo token and provide clear instructions for its use in local development.
This commit is contained in:
@@ -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