Compare commits

...
2 Commits
Author SHA1 Message Date
DenozordecandCursor 4d83b8d673 feat(auth): integrate portal JWT for enhanced authentication and authorization
CI / changes (push) Successful in 6s
CI / commitlint (push) Skipped
CI / openapi (push) Successful in 27s
CI / web (push) Successful in 51s
CI / go (push) Successful in 2m19s
CI / bird2 (push) Successful in 13s
CI / release (push) Successful in 4m24s
Added support for portal JWT authentication, enabling single sign-on (SSO) capabilities. Updated the application to handle JWT claims for user permissions and roles, enhancing security and access control. Refactored relevant components and API routes to accommodate the new authentication flow, ensuring a seamless user experience. Updated documentation to reflect the new authentication requirements and configurations.

Co-authored-by: Cursor <[email protected]>
2026-07-18 23:23:52 +07:00
DenozordecandCursor 2820cff988 refactor(web): streamline /network page with ReUI kit
CI / changes (push) Successful in 6s
CI / commitlint (push) Skipped
CI / openapi (push) Skipped
CI / go (push) Skipped
CI / bird2 (push) Skipped
CI / web (push) Successful in 1m14s
CI / release (push) Successful in 4m23s
Сжать экран «Сеть»: KPI (stats-12) и вкладки Пиры/Спикеры через ResourcePage; убраны дубли charts и заглушка control-plane.

Co-authored-by: Cursor <[email protected]>
2026-07-18 01:12:42 +07:00
54 changed files with 2318 additions and 601 deletions
+28
View File
@@ -0,0 +1,28 @@
# EvoBGP web (Vite) — переменные окружения.
# Скопируйте в apps/web/.env.local (файл в .gitignore) и заполните.
# ---------------------------------------------------------------------------
# ReUI Pro/Ultimate — ключ с https://reui.io/account (для `pnpm dlx shadcn add @reui/*`)
# ---------------------------------------------------------------------------
# REUI_LICENSE_KEY=
# ---------------------------------------------------------------------------
# App Switcher — JSON с описанием шапки «Приложения» (fallback, когда portal
# недоступен либо VITE_AUTH_ENABLED=false).
# Схема: см. apps/web/src/lib/app-switcher-config.ts.
# ---------------------------------------------------------------------------
# VITE_APP_SWITCHER={"menuLabel":"Приложения","apps":[...]}
# ---------------------------------------------------------------------------
# Auth-portal SSO
# ---------------------------------------------------------------------------
# Включает JWT-гейт через auth-portal вместо локального evobgp_api_token.
# Пример:
# VITE_AUTH_ENABLED=true
# VITE_AUTH_PORTAL_URL=https://auth.shnt.top
#
# Backend опционально может отдавать GET /v1/auth/config
# ({ "required": true, "portal_url": "https://auth.shnt.top" }) —
# ответ имеет приоритет над VITE_* и позволяет менять режим без пересборки.
# VITE_AUTH_ENABLED=false
# VITE_AUTH_PORTAL_URL=http://localhost:5175
+21 -2
View File
@@ -45,8 +45,10 @@ import type { ComponentType, CSSProperties, ReactNode } from 'react'
import { AppSwitcher } from '@/components/app-switcher'
import { AppsMenu } from '@/components/layout/apps-menu'
import { CommandPalette, type CommandPaletteItem } from '@/components/layout/command-palette'
import { NavUser } from '@/components/layout/nav-user'
import { SystemMonitorPopover } from '@/components/layout/system-monitor-popover'
import { ModeToggle } from '@/components/mode-toggle'
import { can, isAuthEnabled, permissionForPath } from '@/lib/auth'
interface NavItem {
to: string
@@ -54,6 +56,8 @@ interface NavItem {
icon: ComponentType<{ className?: string }>
description?: string
search?: Record<string, string>
/** Explicit permission override; when omitted derived from `to`. */
permission?: string
}
interface NavGroup {
@@ -103,6 +107,18 @@ const NAV_GROUPS: NavGroup[] = [
const ALL_NAV_ITEMS = NAV_GROUPS.flatMap((g) => g.items)
/** Nav visible for the current claim (or all items when portal auth is off). */
function useVisibleNavGroups(): NavGroup[] {
if (!isAuthEnabled()) return NAV_GROUPS
return NAV_GROUPS.map((group) => ({
...group,
items: group.items.filter((item) => {
const perm = item.permission ?? permissionForPath(item.to)
return !perm || can(perm)
}),
})).filter((group) => group.items.length > 0)
}
const ROUTE_LABELS: Record<string, string> = Object.fromEntries(
ALL_NAV_ITEMS.map((i) => [i.to, i.label]),
)
@@ -127,6 +143,7 @@ const COMMAND_ITEMS: CommandPaletteItem[] = ALL_NAV_ITEMS.map((item) => ({
*/
export function AppShell({ children }: { children: ReactNode }) {
const pathname = useRouterState({ select: (s) => s.location.pathname })
const visibleGroups = useVisibleNavGroups()
const activeItem =
ALL_NAV_ITEMS.find((i) => pathname === i.to || (i.to !== '/' && pathname.startsWith(`${i.to}/`))) ??
ALL_NAV_ITEMS[0]
@@ -152,7 +169,7 @@ export function AppShell({ children }: { children: ReactNode }) {
<AppSwitcher />
</SidebarHeader>
<SidebarContent>
{NAV_GROUPS.map((group) => (
{visibleGroups.map((group) => (
<SidebarGroup key={group.label}>
<SidebarGroupLabel>{group.label}</SidebarGroupLabel>
<SidebarGroupContent>
@@ -178,7 +195,9 @@ export function AppShell({ children }: { children: ReactNode }) {
</SidebarGroup>
))}
</SidebarContent>
<SidebarFooter />
<SidebarFooter>
<NavUser />
</SidebarFooter>
</Sidebar>
<SidebarInset>
<header className="bg-background sticky top-0 z-10 flex h-12 shrink-0 items-center gap-2 border-b px-4 md:px-6">
+18 -7
View File
@@ -15,6 +15,7 @@ import {
CURRENT_APP_ID,
} from '@/lib/app-switcher-config'
import { useAppSwitcherConfig } from '@/hooks/use-app-switcher'
import { authPortalUrl, isAuthEnabled } from '@/lib/auth'
/** Header apps grid — app-shell-12 AppsMenu. @see https://reui.io/preview/base/app-shell-12 */
export function AppsMenu() {
@@ -79,13 +80,23 @@ export function AppsMenu() {
})}
</div>
<DropdownMenuSeparator />
<DropdownMenuItem
nativeButton={false}
render={<Link to="/settings" search={{ tab: 'connection' }} />}
className="justify-center text-sm font-medium"
>
Настройки
</DropdownMenuItem>
{isAuthEnabled() ? (
<DropdownMenuItem
nativeButton={false}
render={<a href={authPortalUrl()} />}
className="justify-center text-sm font-medium"
>
Настроить на портале
</DropdownMenuItem>
) : (
<DropdownMenuItem
nativeButton={false}
render={<Link to="/settings" search={{ tab: 'connection' }} />}
className="justify-center text-sm font-medium"
>
Настройки
</DropdownMenuItem>
)}
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
+158
View File
@@ -0,0 +1,158 @@
import { Link } from '@tanstack/react-router'
import {
ChevronsUpDownIcon,
ExternalLinkIcon,
LogOutIcon,
SettingsIcon,
} from 'lucide-react'
import { Avatar, AvatarFallback } from '@evobgp/ui/components/avatar'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@evobgp/ui/components/dropdown-menu'
import {
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
useSidebar,
} from '@evobgp/ui/components/sidebar'
import { setToken as setApiToken, TOKEN_STORAGE_KEY } from '@/lib/api-client'
import {
authPortalUrl,
clearPortalToken,
getClaims,
isAuthEnabled,
redirectToPortalLogout,
resetPortalHandoff,
} from '@/lib/auth'
/**
* Sidebar footer account menu.
* Portal mode → shows JWT email + logout via auth-portal.
* Local mode → shows the API-key hint + clears the local token.
* @see https://reui.io/preview/base/app-shell-12
*/
function initials(source: string): string {
const base = source.trim()
if (!base) return '?'
const parts = base.split(/\s+/).filter(Boolean)
if (parts.length >= 2) {
return `${parts[0]![0] ?? ''}${parts[1]![0] ?? ''}`.toUpperCase()
}
return base.slice(0, 2).toUpperCase()
}
export function NavUser() {
const { isMobile } = useSidebar()
const authOn = isAuthEnabled()
const claims = getClaims()
const name = claims?.name?.trim() || (authOn ? 'Пользователь' : 'Гость')
const email =
claims?.email?.trim() ||
(authOn ? '' : 'локальный API-токен')
const fallback = initials(name || email)
function handleSignOut() {
if (authOn) {
clearPortalToken()
resetPortalHandoff()
redirectToPortalLogout()
return
}
setApiToken(null)
if (typeof window !== 'undefined') {
window.localStorage.removeItem(TOKEN_STORAGE_KEY)
window.location.assign('/settings?tab=connection&reason=token-required')
}
}
return (
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger
render={
<SidebarMenuButton
size="lg"
className="data-popup-open:bg-sidebar-accent data-popup-open:text-sidebar-accent-foreground"
/>
}
>
<Avatar className="size-8 rounded-lg">
<AvatarFallback className="rounded-lg text-xs">
{fallback}
</AvatarFallback>
</Avatar>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-semibold">{name}</span>
<span className="truncate text-xs text-muted-foreground">
{email || '—'}
</span>
</div>
<ChevronsUpDownIcon className="ml-auto size-4" />
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-(--anchor-width) min-w-56 rounded-lg"
side={isMobile ? 'bottom' : 'right'}
align="end"
sideOffset={4}
>
<DropdownMenuGroup>
<DropdownMenuLabel className="flex items-center gap-2 py-2 font-normal text-foreground">
<Avatar className="size-8 rounded-lg">
<AvatarFallback className="rounded-lg text-xs">
{fallback}
</AvatarFallback>
</Avatar>
<div className="grid min-w-0 flex-1 text-left text-sm leading-tight">
<span className="truncate font-semibold">{name}</span>
<span className="truncate text-xs text-muted-foreground">
{email || '—'}
</span>
</div>
</DropdownMenuLabel>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem
nativeButton={false}
render={<Link to="/settings" search={{ tab: 'connection' }} />}
>
<SettingsIcon aria-hidden />
Настройки UI
</DropdownMenuItem>
{authOn ? (
<DropdownMenuItem
nativeButton={false}
render={<a href={authPortalUrl()} />}
>
<ExternalLinkIcon aria-hidden />
Открыть Auth Portal
</DropdownMenuItem>
) : null}
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem onClick={handleSignOut}>
<LogOutIcon aria-hidden />
{authOn ? 'Выйти' : 'Сбросить токен'}
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
</SidebarMenu>
)
}
@@ -0,0 +1,115 @@
import { AlertTriangle, Network, ServerCog, Share2 } from 'lucide-react'
import { Badge } from '@/components/reui/badge'
import { KpiStatGrid, type KpiStatCardData } from '@/components/reui-kit'
import { aggregateNetworkMetrics } from '@/queries/overview'
import type { BirdStatus, PeerRow, SpeakerRow } from '@/types/api'
/**
* Network page KPI strip.
* @see https://reui.io/preview/base/stats-12
*/
export function NetworkKpi({
peers,
speakers,
bird,
loading,
}: {
peers: PeerRow[]
speakers: SpeakerRow[]
bird?: BirdStatus
loading?: boolean
}) {
const net = aggregateNetworkMetrics(peers, speakers)
const birdHealthy = bird?.healthy
const birdSessions =
bird != null ? `${bird.bgp_established} / ${bird.bgp_sessions_total}` : '—'
const cards: KpiStatCardData[] = [
{
id: 'peers-established',
icon: <Share2 aria-hidden />,
iconClassName: 'text-success',
label: 'Пиры Established',
value: loading ? '—' : `${net.peersEstablished}/${net.peersEnabled}`,
footer: (
<Badge variant="success-light" size="sm">
{loading ? '…' : `${net.peersTotal} в каталоге`}
</Badge>
),
to: '/network',
search: { tab: 'peers' },
},
{
id: 'speakers-online',
icon: <ServerCog aria-hidden />,
iconClassName: 'text-info',
label: 'Спикеры online',
value: loading ? '—' : `${net.speakersOnline}/${net.speakersTotal}`,
footer: (
<Badge
variant={
net.speakersOnline === net.speakersTotal && net.speakersTotal > 0
? 'success-light'
: 'warning-light'
}
size="sm"
>
{loading ? '…' : 'live'}
</Badge>
),
to: '/network',
search: { tab: 'speakers' },
},
{
id: 'mismatches',
icon: <AlertTriangle aria-hidden />,
iconClassName: net.peersMismatch > 0 ? 'text-warning' : 'text-muted-foreground',
label: 'Расхождения',
value: loading ? '—' : String(net.peersMismatch),
variant: net.peersMismatch > 0 ? 'warning' : 'default',
footer: (
<Badge variant={net.peersMismatch > 0 ? 'warning-light' : 'outline'} size="sm">
{loading ? '…' : net.peersMismatch > 0 ? 'проверить' : 'в норме'}
</Badge>
),
to: '/network',
search: { tab: 'peers' },
},
{
id: 'bird',
icon: <Network aria-hidden />,
iconClassName:
birdHealthy === false ? 'text-destructive' : 'text-primary',
label: 'BIRD',
value: loading ? '—' : birdSessions,
variant: birdHealthy === false ? 'destructive' : 'default',
footer: (
<Badge
variant={
birdHealthy === true
? 'success-light'
: birdHealthy === false
? 'destructive-light'
: 'outline'
}
size="sm"
>
{loading
? '…'
: birdHealthy === true
? 'в норме'
: birdHealthy === false
? 'проблема'
: 'н/д'}
</Badge>
),
},
]
return (
<section aria-label="Ключевые метрики сети">
<KpiStatGrid cards={cards} isLoading={loading} skeletonCount={4} />
</section>
)
}
@@ -1,17 +1,76 @@
import { useMemo, useState } from 'react'
import { Plus } from 'lucide-react'
import { Plus, SearchIcon, ActivityIcon } from 'lucide-react'
import { Button } from '@evobgp/ui/components/button'
import { Tabs, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs'
import { DataGridCard } from '@/components/data-grid-shell'
import { NetworkPeersGrid } from '@/components/network/network-peers-grid'
import { PeerFormDialog } from '@/components/network/peer-form-dialog'
import { QueryState } from '@/components/query-state'
import { TableSkeleton } from '@/components/skeletons'
import {
getPeerFilterFieldValue,
peerColumns,
peerTabFilter,
} from '@/components/network/network-peers-grid'
import {
createFilter,
type Filter,
type FilterFieldConfig,
} from '@/components/reui/filters'
import { ResourcePage, renderSingleSelectedLabel } from '@/components/reui-kit'
import type { PeerRow, SpeakerRow } from '@/types/api'
type PeerTab = 'all' | 'established' | 'pending' | 'disabled'
/**
* BGP peers list — ResourcePage (Frame + tabs + Filters + DataGrid).
* @see https://reui.io/preview/base/data-grid-filtering-2
*/
const PEER_TABS = [
{ id: 'all', label: 'Все' },
{ id: 'established', label: 'Established' },
{ id: 'pending', label: 'Ожидание' },
{ id: 'disabled', label: 'Выключены' },
]
const SESSION_STATE_OPTIONS = [
{ value: 'Established', label: 'Established' },
{ value: 'Idle', label: 'Idle' },
{ value: 'Active', label: 'Active' },
{ value: 'Connect', label: 'Connect' },
{ value: 'OpenSent', label: 'OpenSent' },
{ value: 'OpenConfirm', label: 'OpenConfirm' },
]
function createDefaultPeerFilters(): Filter[] {
return [createFilter('name', 'contains', [''])]
}
const peerFilterFields: FilterFieldConfig[] = [
{
key: 'name',
label: 'Имя',
icon: <SearchIcon className="size-3.5" aria-hidden />,
type: 'text',
className: 'w-48',
placeholder: 'Поиск по имени…',
},
{
key: 'neighbor',
label: 'Сосед',
icon: <SearchIcon className="size-3.5" aria-hidden />,
type: 'text',
className: 'w-48',
placeholder: 'Адрес соседа…',
},
{
key: 'session_state',
label: 'Состояние',
icon: <ActivityIcon className="size-3.5" aria-hidden />,
type: 'select',
searchable: false,
className: 'w-[160px]',
options: SESSION_STATE_OPTIONS,
customValueRenderer: (values) =>
renderSingleSelectedLabel(values, SESSION_STATE_OPTIONS),
},
]
interface NetworkPeersCardProps {
items: PeerRow[]
@@ -22,24 +81,6 @@ interface NetworkPeersCardProps {
onRetry: () => void
}
function filterPeers(items: PeerRow[], tab: PeerTab): PeerRow[] {
if (tab === 'all') return items
if (tab === 'disabled') return items.filter((p) => p.enabled === false)
const enabled = items.filter((p) => p.enabled !== false)
if (tab === 'established') return enabled.filter((p) => p.session_state === 'Established')
return enabled.filter((p) => p.session_state !== 'Established')
}
function tabCounts(items: PeerRow[]) {
const enabled = items.filter((p) => p.enabled !== false)
return {
all: items.length,
established: enabled.filter((p) => p.session_state === 'Established').length,
pending: enabled.filter((p) => p.session_state !== 'Established').length,
disabled: items.length - enabled.length,
}
}
export function NetworkPeersCard({
items,
speakers,
@@ -49,48 +90,44 @@ export function NetworkPeersCard({
onRetry,
}: NetworkPeersCardProps) {
const [dialogOpen, setDialogOpen] = useState(false)
const [tab, setTab] = useState<PeerTab>('all')
const counts = useMemo(() => tabCounts(items), [items])
const filtered = useMemo(() => filterPeers(items, tab), [items, tab])
const [filters, setFilters] = useState<Filter[]>(createDefaultPeerFilters)
const addButton = useMemo(
() => (
<Button size="sm" type="button" onClick={() => setDialogOpen(true)}>
<Plus />
Добавить пира
</Button>
),
[],
)
return (
<>
<DataGridCard
<ResourcePage
title="Пиры"
description="BGP-соседи и привязка к спикерам"
actions={
<Button size="sm" type="button" onClick={() => setDialogOpen(true)}>
<Plus />
Добавить пира
</Button>
}
>
<div className="px-5 pt-3">
<Tabs value={tab} onValueChange={(v) => setTab(v as PeerTab)} className="w-full">
<TabsList variant="line" className="w-full justify-start gap-6">
<TabsTrigger value="all">Все ({counts.all})</TabsTrigger>
<TabsTrigger value="established">Established ({counts.established})</TabsTrigger>
<TabsTrigger value="pending">Ожидание ({counts.pending})</TabsTrigger>
<TabsTrigger value="disabled">Выключены ({counts.disabled})</TabsTrigger>
</TabsList>
</Tabs>
</div>
<QueryState
data={filtered}
isLoading={isLoading}
isError={isError}
error={error}
empty={filtered.length === 0}
emptyTitle="Нет пиров в выборке"
emptyDescription="Измените фильтр или добавьте BGP-соседа."
skeleton={<TableSkeleton rows={6} cols={4} />}
onRetry={onRetry}
>
{(data) => (
<NetworkPeersGrid items={data} isLoading={isLoading && data.length > 0} />
)}
</QueryState>
</DataGridCard>
tabs={PEER_TABS}
tabFilter={peerTabFilter}
filterFields={peerFilterFields}
filters={filters}
onFiltersChange={setFilters}
onClearFilters={() => setFilters(createDefaultPeerFilters())}
getFilterFieldValue={getPeerFilterFieldValue}
columns={peerColumns}
data={items}
getRowId={(row) => row.id}
isLoading={isLoading}
isError={isError}
error={error instanceof Error ? error : null}
onRetry={onRetry}
primaryAction={addButton}
emptyState={{
title: 'Нет пиров',
description: 'Добавьте первого BGP-соседа.',
action: addButton,
}}
/>
<PeerFormDialog open={dialogOpen} onOpenChange={setDialogOpen} speakers={speakers} />
</>
@@ -1,90 +1,77 @@
import { ColumnDef } from '@tanstack/react-table'
import { useMemo } from 'react'
import type { ColumnDef } from '@tanstack/react-table'
import { CategoryBadge } from '@/components/category-badge'
import { DataGridPrimaryCell } from '@/components/data-grid-cell'
import { DataGridSection } from '@/components/data-grid-shell'
import { StatusBadge } from '@/components/status-badge'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
import { bgpSessionStateRu } from '@/lib/ui-labels'
import type { PeerRow } from '@/types/api'
export function NetworkPeersGrid({
items,
isLoading = false,
}: {
items: PeerRow[]
isLoading?: boolean
}) {
const columns = useMemo<ColumnDef<PeerRow>[]>(
() => [
{
id: 'name',
accessorFn: (row) => row.name ?? row.neighbor,
header: ({ column }) => <DataGridColumnHeader column={column} title="Имя" />,
cell: ({ row }) => (
<DataGridPrimaryCell
title={row.original.name ?? row.original.neighbor}
subtitle={row.original.name ? row.original.neighbor : undefined}
accent="primary"
/>
),
meta: { headerTitle: 'Имя' },
},
{
accessorKey: 'neighbor',
header: ({ column }) => <DataGridColumnHeader column={column} title="Адрес соседа" />,
cell: ({ row }) => (
<DataGridPrimaryCell title={row.original.neighbor} accent="mono" />
),
meta: { headerTitle: 'Адрес соседа' },
},
{
accessorKey: 'remote_asn',
header: ({ column }) => <DataGridColumnHeader column={column} title="ASN" />,
cell: ({ row }) => (
<span className="font-mono text-xs">{row.original.remote_asn ?? '—'}</span>
),
meta: { headerTitle: 'ASN' },
},
{
accessorKey: 'session_state',
header: ({ column }) => <DataGridColumnHeader column={column} title="Состояние" />,
cell: ({ row }) => (
<div className="flex items-center gap-1">
<StatusBadge
status={row.original.session_state ?? '—'}
label={bgpSessionStateRu(row.original.session_state)}
/>
{row.original.session_mismatch ? (
<CategoryBadge tone="warning">расхождение</CategoryBadge>
) : null}
</div>
),
meta: { headerTitle: 'Состояние' },
},
],
[],
)
export const peerColumns: ColumnDef<PeerRow, unknown>[] = [
{
id: 'name',
accessorFn: (row) => row.name ?? row.neighbor,
header: ({ column }) => <DataGridColumnHeader column={column} title="Имя" />,
cell: ({ row }) => (
<DataGridPrimaryCell
title={row.original.name ?? row.original.neighbor}
subtitle={row.original.name ? row.original.neighbor : undefined}
accent="primary"
/>
),
meta: { headerTitle: 'Имя' },
},
{
accessorKey: 'neighbor',
header: ({ column }) => <DataGridColumnHeader column={column} title="Адрес соседа" />,
cell: ({ row }) => (
<DataGridPrimaryCell title={row.original.neighbor} accent="mono" />
),
meta: { headerTitle: 'Адрес соседа' },
},
{
accessorKey: 'remote_asn',
header: ({ column }) => <DataGridColumnHeader column={column} title="ASN" />,
cell: ({ row }) => (
<span className="font-mono text-xs">{row.original.remote_asn ?? '—'}</span>
),
meta: { headerTitle: 'ASN' },
},
{
accessorKey: 'session_state',
header: ({ column }) => <DataGridColumnHeader column={column} title="Состояние" />,
cell: ({ row }) => (
<div className="flex items-center gap-1">
<StatusBadge
status={row.original.session_state ?? ''}
label={bgpSessionStateRu(row.original.session_state)}
/>
{row.original.session_mismatch ? (
<CategoryBadge tone="warning">расхождение</CategoryBadge>
) : null}
</div>
),
meta: { headerTitle: 'Состояние' },
},
]
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
data: items,
columns,
getSearchText: (row) =>
`${row.name ?? ''} ${row.neighbor} ${row.remote_asn ?? ''} ${row.session_state ?? ''}`,
getRowId: (row) => row.id,
})
export function getPeerFilterFieldValue(item: PeerRow, field: string): unknown {
switch (field) {
case 'name':
return item.name ?? item.neighbor
case 'neighbor':
return item.neighbor
case 'session_state':
return item.session_state
default:
return undefined
}
}
return (
<DataGridSection
table={table}
recordCount={filteredCount}
isLoading={isLoading}
emptyMessage="Нет пиров"
searchValue={globalFilter}
onSearchChange={setGlobalFilter}
searchPlaceholder="Поиск пиров…"
/>
)
export function peerTabFilter(item: PeerRow, tabId: string): boolean {
if (tabId === 'disabled') return item.enabled === false
const enabled = item.enabled !== false
if (tabId === 'established') return enabled && item.session_state === 'Established'
if (tabId === 'pending') return enabled && item.session_state !== 'Established'
return true
}
@@ -1,15 +1,65 @@
import { useState } from 'react'
import { Plus } from 'lucide-react'
import { useMemo, useState } from 'react'
import { Plus, SearchIcon, TagIcon } from 'lucide-react'
import { Button } from '@evobgp/ui/components/button'
import { DataGridCard } from '@/components/data-grid-shell'
import { NetworkSpeakersGrid } from '@/components/network/network-speakers-grid'
import { SpeakerFormDialog } from '@/components/network/speaker-form-dialog'
import { QueryState } from '@/components/query-state'
import { TableSkeleton } from '@/components/skeletons'
import {
getSpeakerFilterFieldValue,
speakerColumns,
speakerTabFilter,
} from '@/components/network/network-speakers-grid'
import {
createFilter,
type Filter,
type FilterFieldConfig,
} from '@/components/reui/filters'
import { ResourcePage, renderSingleSelectedLabel } from '@/components/reui-kit'
import type { SpeakerRow } from '@/types/api'
/**
* BGP speakers list — ResourcePage (Frame + tabs + Filters + DataGrid).
* @see https://reui.io/preview/base/data-grid-filtering-2
*/
const SPEAKER_TABS = [
{ id: 'all', label: 'Все' },
{ id: 'online', label: 'Online' },
{ id: 'offline', label: 'Offline' },
]
const ROLE_OPTIONS = [
{ value: 'primary', label: 'primary' },
{ value: 'secondary', label: 'secondary' },
{ value: 'speaker', label: 'speaker' },
]
function createDefaultSpeakerFilters(): Filter[] {
return [createFilter('endpoint', 'contains', [''])]
}
const speakerFilterFields: FilterFieldConfig[] = [
{
key: 'endpoint',
label: 'Конечная точка',
icon: <SearchIcon className="size-3.5" aria-hidden />,
type: 'text',
className: 'w-52',
placeholder: 'endpoint…',
},
{
key: 'role',
label: 'Роль',
icon: <TagIcon className="size-3.5" aria-hidden />,
type: 'select',
searchable: false,
className: 'w-[140px]',
options: ROLE_OPTIONS,
customValueRenderer: (values) =>
renderSingleSelectedLabel(values, ROLE_OPTIONS),
},
]
interface NetworkSpeakersCardProps {
items: SpeakerRow[]
isLoading: boolean
@@ -26,35 +76,44 @@ export function NetworkSpeakersCard({
onRetry,
}: NetworkSpeakersCardProps) {
const [dialogOpen, setDialogOpen] = useState(false)
const [filters, setFilters] = useState<Filter[]>(createDefaultSpeakerFilters)
const addButton = useMemo(
() => (
<Button size="sm" type="button" onClick={() => setDialogOpen(true)}>
<Plus />
Добавить спикера
</Button>
),
[],
)
return (
<>
<DataGridCard
<ResourcePage
title="Спикеры"
description="BIRD-агенты на нодах tenant"
actions={
<Button size="sm" type="button" onClick={() => setDialogOpen(true)}>
<Plus />
Добавить спикера
</Button>
}
>
<QueryState
data={items}
isLoading={isLoading}
isError={isError}
error={error}
empty={items.length === 0}
emptyTitle="Нет спикеров"
emptyDescription="Добавьте первого BIRD-агента на ноде."
skeleton={<TableSkeleton rows={6} cols={4} />}
onRetry={onRetry}
>
{(data) => (
<NetworkSpeakersGrid items={data} isLoading={isLoading && data.length > 0} />
)}
</QueryState>
</DataGridCard>
tabs={SPEAKER_TABS}
tabFilter={speakerTabFilter}
filterFields={speakerFilterFields}
filters={filters}
onFiltersChange={setFilters}
onClearFilters={() => setFilters(createDefaultSpeakerFilters())}
getFilterFieldValue={getSpeakerFilterFieldValue}
columns={speakerColumns}
data={items}
getRowId={(row) => row.id}
isLoading={isLoading}
isError={isError}
error={error instanceof Error ? error : null}
onRetry={onRetry}
primaryAction={addButton}
emptyState={{
title: 'Нет спикеров',
description: 'Добавьте первого BIRD-агента на ноде.',
action: addButton,
}}
/>
<SpeakerFormDialog open={dialogOpen} onOpenChange={setDialogOpen} />
</>
@@ -1,87 +1,78 @@
import { ColumnDef } from '@tanstack/react-table'
import { useMemo } from 'react'
import type { ColumnDef } from '@tanstack/react-table'
import { CategoryBadge } from '@/components/category-badge'
import { DataGridPrimaryCell } from '@/components/data-grid-cell'
import { DataGridSection } from '@/components/data-grid-shell'
import { StatusBadge } from '@/components/status-badge'
import { Badge } from '@/components/reui/badge'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
import { speakerOnlineLabel } from '@/lib/ui-labels'
import type { SpeakerRow } from '@/types/api'
export function NetworkSpeakersGrid({
items,
isLoading = false,
}: {
items: SpeakerRow[]
isLoading?: boolean
}) {
const columns = useMemo<ColumnDef<SpeakerRow>[]>(
() => [
{
accessorKey: 'endpoint',
header: ({ column }) => <DataGridColumnHeader column={column} title="Конечная точка" />,
cell: ({ row }) => (
<DataGridPrimaryCell title={row.original.endpoint} accent="mono" />
),
meta: { headerTitle: 'Конечная точка' },
},
{
accessorKey: 'role',
header: ({ column }) => <DataGridColumnHeader column={column} title="Роль" />,
cell: ({ row }) => <CategoryBadge>{row.original.role}</CategoryBadge>,
meta: { headerTitle: 'Роль' },
},
{
id: 'agent',
enableSorting: false,
header: 'Агент',
cell: ({ row }) => {
const live = row.original.live
if (live?.agent_ok === true) return <StatusBadge status="ok" label={speakerOnlineLabel(true)} />
if (live?.agent_ok === false) return <StatusBadge status="error" label={speakerOnlineLabel(false)} />
return <Badge variant="outline" size="sm" radius="full"></Badge>
},
meta: { headerTitle: 'Агент' },
},
{
id: 'bgp',
enableSorting: false,
header: 'BGP',
cell: ({ row }) => {
const live = row.original.live
if (!live) return '—'
return (
<span className="text-xs">
{live.bgp_established ?? 0} / {live.bgp_sessions_total ?? 0}
</span>
)
},
meta: { headerTitle: 'BGP' },
},
],
[],
)
export const speakerColumns: ColumnDef<SpeakerRow, unknown>[] = [
{
accessorKey: 'endpoint',
header: ({ column }) => <DataGridColumnHeader column={column} title="Конечная точка" />,
cell: ({ row }) => (
<DataGridPrimaryCell title={row.original.endpoint} accent="mono" />
),
meta: { headerTitle: 'Конечная точка' },
},
{
accessorKey: 'role',
header: ({ column }) => <DataGridColumnHeader column={column} title="Роль" />,
cell: ({ row }) => <CategoryBadge>{row.original.role}</CategoryBadge>,
meta: { headerTitle: 'Роль' },
},
{
id: 'agent',
enableSorting: false,
header: 'Агент',
cell: ({ row }) => {
const live = row.original.live
if (live?.agent_ok === true) {
return <StatusBadge status="ok" label={speakerOnlineLabel(true)} />
}
if (live?.agent_ok === false) {
return <StatusBadge status="error" label={speakerOnlineLabel(false)} />
}
return (
<Badge variant="outline" size="sm" radius="full">
</Badge>
)
},
meta: { headerTitle: 'Агент' },
},
{
id: 'bgp',
enableSorting: false,
header: 'BGP',
cell: ({ row }) => {
const live = row.original.live
if (!live) return '—'
return (
<span className="text-xs">
{live.bgp_established ?? 0} / {live.bgp_sessions_total ?? 0}
</span>
)
},
meta: { headerTitle: 'BGP' },
},
]
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
data: items,
columns,
getSearchText: (row) =>
`${row.endpoint} ${row.role} ${row.agent_domain ?? ''} ${row.node_ipv4 ?? ''}`,
getRowId: (row) => row.id,
})
export function getSpeakerFilterFieldValue(item: SpeakerRow, field: string): unknown {
switch (field) {
case 'endpoint':
return item.endpoint
case 'role':
return item.role
default:
return undefined
}
}
return (
<DataGridSection
table={table}
recordCount={filteredCount}
isLoading={isLoading}
emptyMessage="Нет спикеров"
searchValue={globalFilter}
onSearchChange={setGlobalFilter}
searchPlaceholder="Поиск спикеров…"
/>
)
export function speakerTabFilter(item: SpeakerRow, tabId: string): boolean {
if (tabId === 'online') return item.live?.agent_ok === true
if (tabId === 'offline') return item.live?.agent_ok !== true
return true
}
+29 -6
View File
@@ -1,19 +1,42 @@
import { useMemo } from 'react'
import { useQuery } from '@tanstack/react-query'
import {
DEFAULT_APP_SWITCHER_CONFIG,
getAppSwitcherConfig,
getAppUrl as getAppUrlFromConfig,
type AppSwitcherConfig,
} from '@/lib/app-switcher-config'
import { appSwitcherQueryOptions } from '@/queries/app-switcher'
import { getClaims, isAuthEnabled } from '@/lib/auth'
/** Env-backed app switcher (no DB API in EvoBGP v1). */
/**
* Portal-first switcher: tries `/api/v1/app-switcher` and filters entries by
* the current JWT `apps` claim; falls back to `VITE_APP_SWITCHER`/defaults
* when auth-portal is disabled or unavailable.
*/
export function useAppSwitcherConfig(): {
config: AppSwitcherConfig
isLoading: boolean
} {
return {
config: getAppSwitcherConfig(),
isLoading: false,
}
const authOn = isAuthEnabled()
const { data, isLoading } = useQuery({
...appSwitcherQueryOptions(),
enabled: authOn,
})
const claims = getClaims()
const config = useMemo<AppSwitcherConfig>(() => {
const raw = data ?? DEFAULT_APP_SWITCHER_CONFIG
if (!authOn) return raw
const allowed = claims?.apps
if (!allowed?.length) return raw
const set = new Set(allowed)
const apps = raw.apps.filter((a) => set.has(a.id))
if (!apps.length) return raw
return { ...raw, apps }
}, [data, authOn, claims?.apps])
return { config, isLoading: authOn && isLoading }
}
export function useAppUrl(appId: string): string | undefined {
+11
View File
@@ -30,8 +30,19 @@ export function normalizeApiToken(raw: string): string {
return t
}
/** Portal JWT storage key mirrored from `@/lib/auth`. Kept local to avoid a
* cycle when `auth` starts pulling from `api-client` for the config endpoint. */
const PORTAL_TOKEN_STORAGE_KEY = 'evobgp_portal_token'
/**
* Bearer selection: portal JWT wins over the legacy API-key. When auth-portal
* is disabled or hasn't issued a token yet we fall back to the local API-key
* (`evobgp_api_token`) so curl-style tooling keeps working.
*/
function getToken(): string | null {
if (typeof window === 'undefined') return null
const portal = window.localStorage.getItem(PORTAL_TOKEN_STORAGE_KEY)
if (portal && portal.trim()) return portal.trim()
const raw = window.localStorage.getItem(TOKEN_STORAGE_KEY)
if (!raw) return null
const normalized = normalizeApiToken(raw)
+5 -3
View File
@@ -8,7 +8,9 @@ import {
} from 'lucide-react'
import { z } from 'zod'
export const CURRENT_APP_ID = 'evobgp'
import { CURRENT_APP_ID } from '@/lib/auth'
export { CURRENT_APP_ID }
const appSwitcherIconSchema = z.enum(['server', 'cloud', 'globe', 'dashboard', 'chart'])
@@ -44,7 +46,7 @@ export const DEFAULT_APP_SWITCHER_CONFIG: AppSwitcherConfig = {
menuLabel: 'Приложения',
apps: [
{
id: 'vps-tracker',
id: 'vps',
name: 'VPS Tracker',
subtitle: 'Учёт виртуальных серверов',
url: 'http://192.168.100.67:3001',
@@ -60,7 +62,7 @@ export const DEFAULT_APP_SWITCHER_CONFIG: AppSwitcherConfig = {
shortcut: '⌘2',
},
{
id: 'evobgp',
id: 'bgp',
name: 'EvoBGP',
subtitle: 'BGP маршрутизация',
url: 'http://192.168.100.67:3000',
+303
View File
@@ -0,0 +1,303 @@
/**
* Portal JWT SSO integration for EvoBGP UI.
*
* Two independent auth channels:
* - Portal JWT (this module) — SSO from auth-portal, used to gate UI + Bearer
* to EvoBGP API when the API accepts portal-issued tokens.
* - Local API key (see `@/lib/api-client`) — legacy `evobgp_api_token`
* in localStorage; used when auth-portal is disabled or as a fallback.
*
* `VITE_AUTH_ENABLED=false` → keep the API-key gate.
* `VITE_AUTH_ENABLED=true` → require portal JWT; API-key kept only for tools
* (curl/dev) and as backup.
*/
const TOKEN_KEY = 'evobgp_portal_token'
const HANDOFF_KEY = 'evobgp_portal_401_handoff'
const HANDOFF_AT_KEY = 'evobgp_portal_handoff_at'
/** Min gap between portal handoffs — breaks SSO↔401 redirect storms. */
const HANDOFF_COOLDOWN_MS = 12_000
/** EvoBGP is `bgp` in the auth-portal registry (see APP_IDS). */
export const CURRENT_APP_ID = 'bgp'
export type AccessClaims = {
sub: string
email: string
name: string
apps: string[]
permissions: string[]
is_admin?: boolean
iss?: string
exp?: number
}
export type RuntimeAuthConfig = {
required: boolean
portalUrl: string
}
let runtimeConfig: RuntimeAuthConfig | null = null
let runtimeConfigPromise: Promise<RuntimeAuthConfig> | null = null
function viteAuthEnabled(): boolean {
return (
import.meta.env.VITE_AUTH_ENABLED === 'true' ||
import.meta.env.VITE_AUTH_ENABLED === '1'
)
}
function vitePortalUrl(): string {
return (import.meta.env.VITE_AUTH_PORTAL_URL ?? 'http://localhost:5175').replace(
/\/$/,
'',
)
}
/**
* Load auth mode from EvoBGP API (Docker-friendly). Falls back to VITE_* flags
* when the endpoint isn't implemented (404) or the API is unreachable.
*
* Note: EvoBGP uses `/v1/...` (not `/api/v1/...`).
*/
export async function ensureAuthConfig(): Promise<RuntimeAuthConfig> {
if (runtimeConfig) return runtimeConfig
if (runtimeConfigPromise) return runtimeConfigPromise
runtimeConfigPromise = (async () => {
try {
const res = await fetch('/v1/auth/config', {
headers: { Accept: 'application/json' },
})
if (res.ok) {
const data = (await res.json()) as {
required?: boolean
portal_url?: string
}
runtimeConfig = {
required: Boolean(data.required) || viteAuthEnabled(),
portalUrl: (data.portal_url || vitePortalUrl()).replace(/\/$/, ''),
}
return runtimeConfig
}
} catch {
/* ignore — fall through to vite defaults */
}
runtimeConfig = {
required: viteAuthEnabled(),
portalUrl: vitePortalUrl(),
}
return runtimeConfig
})().finally(() => {
runtimeConfigPromise = null
})
return runtimeConfigPromise
}
export function getAuthConfigSync(): RuntimeAuthConfig | null {
return runtimeConfig
}
export function getPortalToken(): string | null {
if (typeof window === 'undefined') return null
return window.localStorage.getItem(TOKEN_KEY)
}
export function setPortalToken(token: string): void {
if (typeof window === 'undefined') return
window.localStorage.setItem(TOKEN_KEY, token)
}
export function clearPortalToken(): void {
if (typeof window === 'undefined') return
window.localStorage.removeItem(TOKEN_KEY)
}
export function isAuthEnabled(): boolean {
if (runtimeConfig) return runtimeConfig.required
return viteAuthEnabled()
}
export function authPortalUrl(): string {
if (runtimeConfig?.portalUrl) return runtimeConfig.portalUrl
return vitePortalUrl()
}
export function isPortalHandoffCoolingDown(): boolean {
if (typeof window === 'undefined') return false
const raw = window.sessionStorage.getItem(HANDOFF_AT_KEY)
if (!raw) return false
const at = Number(raw)
if (!Number.isFinite(at)) return false
return Date.now() - at < HANDOFF_COOLDOWN_MS
}
export function markPortalHandoff(): void {
if (typeof window === 'undefined') return
window.sessionStorage.setItem(HANDOFF_KEY, '1')
window.sessionStorage.setItem(HANDOFF_AT_KEY, String(Date.now()))
}
export function clearPortalHandoffFlag(): void {
if (typeof window === 'undefined') return
window.sessionStorage.removeItem(HANDOFF_KEY)
}
/** Clear cooldown too — use on intentional logout so next login is allowed. */
export function resetPortalHandoff(): void {
if (typeof window === 'undefined') return
window.sessionStorage.removeItem(HANDOFF_KEY)
window.sessionStorage.removeItem(HANDOFF_AT_KEY)
}
export function hasPortalHandoffFlag(): boolean {
if (typeof window === 'undefined') return false
return window.sessionStorage.getItem(HANDOFF_KEY) === '1'
}
/**
* Redirect to auth-portal SSO. Returns false if cooldown blocks the handoff
* (clears local token) — prevents infinite SSO when API rejects JWT.
*/
export function redirectToPortalLogin(returnTo?: string): boolean {
if (typeof window === 'undefined') return false
if (isPortalHandoffCoolingDown()) {
clearPortalToken()
return false
}
markPortalHandoff()
const callback = returnTo ?? `${window.location.origin}/auth/callback`
const url = new URL(authPortalUrl())
url.searchParams.set('return_to', callback)
window.location.assign(url.toString())
return true
}
/** End portal SSO session (refresh cookie + portal token). */
export function redirectToPortalLogout(): void {
clearPortalToken()
resetPortalHandoff()
if (typeof window === 'undefined') return
window.location.assign(`${authPortalUrl()}/logout`)
}
export function parseHashToken(hash: string): {
accessToken: string | null
expiresAt: string | null
} {
const raw = hash.startsWith('#') ? hash.slice(1) : hash
const params = new URLSearchParams(raw)
return {
accessToken: params.get('access_token'),
expiresAt: params.get('expires_at'),
}
}
export function decodeClaims(token: string): AccessClaims | null {
try {
const parts = token.split('.')
if (parts.length < 2) return null
const json = atob(parts[1]!.replace(/-/g, '+').replace(/_/g, '/'))
const payload = JSON.parse(json) as Record<string, unknown>
return {
sub: String(payload.sub ?? ''),
email: String(payload.email ?? ''),
name: String(payload.name ?? ''),
apps: Array.isArray(payload.apps) ? payload.apps.map(String) : [],
permissions: Array.isArray(payload.permissions)
? payload.permissions.map(String)
: [],
is_admin: Boolean(payload.is_admin),
iss: payload.iss ? String(payload.iss) : undefined,
exp: typeof payload.exp === 'number' ? payload.exp : undefined,
}
} catch {
return null
}
}
export function getClaims(): AccessClaims | null {
const token = getPortalToken()
if (!token) return null
const claims = decodeClaims(token)
if (!claims) return null
if (claims.exp && claims.exp * 1000 < Date.now()) {
clearPortalToken()
return null
}
return claims
}
export function hasPermission(
granted: readonly string[],
required: string,
): boolean {
if (granted.includes(required)) return true
const parts = required.split(':')
if (parts.length !== 3) return false
const [app, section, action] = parts
if (action === 'read') {
return (
granted.includes(`${app}:${section}:write`) ||
granted.includes(`${app}:${section}:admin`)
)
}
if (action === 'write') {
return granted.includes(`${app}:${section}:admin`)
}
return false
}
/** Access-check: pass when portal auth is disabled or claim grants required. */
export function can(required: string): boolean {
if (!isAuthEnabled()) return true
const claims = getClaims()
if (!claims) return false
if (!claims.apps.includes(CURRENT_APP_ID)) return false
return hasPermission(claims.permissions, required)
}
/** Nav path → minimum permission to show the item. Sync with app-shell NAV. */
export function permissionForPath(pathname: string): string | null {
if (pathname === '/' || pathname.startsWith('/dashboard')) {
return 'bgp:dashboard:read'
}
if (pathname.startsWith('/modules')) return 'bgp:modules:read'
if (pathname.startsWith('/lookup')) return 'bgp:lookup:read'
if (pathname.startsWith('/network')) return 'bgp:network:read'
if (pathname.startsWith('/directories')) return 'bgp:directories:read'
if (pathname.startsWith('/operations')) return 'bgp:operations:read'
if (pathname.startsWith('/firewall')) return 'bgp:firewall:read'
if (pathname.startsWith('/schedule')) return 'bgp:schedule:read'
if (pathname.startsWith('/monitoring')) return 'bgp:monitoring:read'
if (pathname.startsWith('/access')) return 'bgp:access:admin'
if (pathname.startsWith('/tenant-settings')) return 'bgp:tenant_settings:admin'
if (pathname.startsWith('/settings')) return 'bgp:settings:read'
return null
}
const FALLBACK_PATH = '/dashboard'
/** First path in the sidebar the current user may open. */
export function firstAllowedPath(): string {
const candidates: readonly string[] = [
'/dashboard',
'/modules',
'/lookup',
'/network',
'/directories',
'/operations',
'/firewall',
'/schedule',
'/monitoring',
'/access',
'/tenant-settings',
'/settings',
]
for (const path of candidates) {
const perm = permissionForPath(path)
if (!perm || can(perm)) return path
}
return FALLBACK_PATH
}
+32
View File
@@ -0,0 +1,32 @@
import { queryOptions } from '@tanstack/react-query'
import { ensureAuthConfig } from '@/lib/auth'
import {
DEFAULT_APP_SWITCHER_CONFIG,
parseAppSwitcherConfig,
type AppSwitcherConfig,
} from '@/lib/app-switcher-config'
export const appSwitcherQueryKey = ['app-switcher', 'portal'] as const
/** Portal contract shape: `{ menuLabel, apps: [{ id, name, url, icon, enabled }] }`. */
async function fetchPortalAppSwitcher(): Promise<AppSwitcherConfig> {
const { portalUrl } = await ensureAuthConfig()
const base = portalUrl.replace(/\/$/, '')
const res = await fetch(`${base}/api/v1/app-switcher`, {
headers: { Accept: 'application/json' },
})
if (!res.ok) throw new Error(`app-switcher ${res.status}`)
const raw = (await res.json()) as unknown
return parseAppSwitcherConfig(JSON.stringify(raw))
}
export function appSwitcherQueryOptions() {
return queryOptions({
queryKey: appSwitcherQueryKey,
queryFn: fetchPortalAppSwitcher,
staleTime: 60_000,
placeholderData: DEFAULT_APP_SWITCHER_CONFIG,
retry: 1,
})
}
+58 -4
View File
@@ -1,15 +1,69 @@
import { createFileRoute, Outlet, redirect } from '@tanstack/react-router'
import { normalizeApiToken, TOKEN_STORAGE_KEY } from '@/lib/api-client'
import {
can,
ensureAuthConfig,
firstAllowedPath,
getClaims,
getPortalToken,
permissionForPath,
redirectToPortalLogin,
} from '@/lib/auth'
/**
* Two-mode gate:
* - VITE_AUTH_ENABLED / API `/v1/auth/config { required: true }`
* → require auth-portal JWT (SSO) + section permission via `can()`.
* - Off → keep legacy `evobgp_api_token` gate (redirect to /settings if empty).
*/
export const Route = createFileRoute('/_auth')({
beforeLoad: ({ location }) => {
// Настройки доступны без токена — сюда попадают при первом входе (в т.ч. для `dev`).
beforeLoad: async ({ location }) => {
const cfg = await ensureAuthConfig()
if (cfg.required) {
const token = getPortalToken()
const claims = getClaims()
if (!token || !claims) {
const ok = redirectToPortalLogin(
`${window.location.origin}/auth/callback`,
)
if (!ok) {
throw redirect({
to: '/auth/callback',
search: { error: 'sso_loop' },
})
}
await new Promise(() => {})
return
}
if (!claims.apps.includes('bgp')) {
throw redirect({
to: '/auth/callback',
search: { error: 'sso_loop' },
})
}
const perm = permissionForPath(location.pathname)
if (perm && !can(perm)) {
const fallback = firstAllowedPath()
if (fallback !== location.pathname) {
throw redirect({ to: fallback as '/dashboard' })
}
}
return
}
// Settings available without token — first-run onboarding (incl. `dev`).
if (location.pathname === '/settings') return
const raw =
typeof window !== 'undefined' ? window.localStorage.getItem(TOKEN_STORAGE_KEY) : null
typeof window !== 'undefined'
? window.localStorage.getItem(TOKEN_STORAGE_KEY)
: null
if (!raw || !normalizeApiToken(raw)) {
throw redirect({ to: '/settings', search: { tab: 'connection', reason: 'token-required' } })
throw redirect({
to: '/settings',
search: { tab: 'connection', reason: 'token-required' },
})
}
},
component: AuthLayout,
+44 -101
View File
@@ -1,28 +1,38 @@
import { createFileRoute, useSearch } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import { Button } from '@evobgp/ui/components/button'
import { PanelCard } from '@/components/panel-card'
import { TabsContent } from '@evobgp/ui/components/tabs'
import { RefreshCw } from 'lucide-react'
import {
DashboardNetworkCapacityCard,
NetworkOverviewAnalyticsCard,
} from '@/components/analytics'
import { BadgeTabs, TabsContent } from '@/components/badge-tabs'
import { CountedLineTabs } from '@/components/counted-line-tabs'
import { NetworkKpi } from '@/components/network/network-kpi'
import { NetworkPeersCard } from '@/components/network/network-peers-card'
import { NetworkSpeakersCard } from '@/components/network/network-speakers-card'
import { PageHeader } from '@/components/page-header'
import { QueryState } from '@/components/query-state'
import { TableSkeleton } from '@/components/skeletons'
import { networkBirdQueryOptions, networkPeersQueryOptions, networkSpeakersQueryOptions } from '@/queries/network'
import { overviewJobsQueryOptions } from '@/queries/overview'
import {
networkBirdQueryOptions,
networkPeersQueryOptions,
networkSpeakersQueryOptions,
} from '@/queries/network'
type NetworkTab = 'peers' | 'speakers'
function parseNetworkTab(value: unknown): NetworkTab {
if (value === 'speakers') return 'speakers'
// legacy: overview | control-plane → peers
return 'peers'
}
/**
* Network ops page — KPI (stats-12) + peers/speakers ResourcePage lists.
* @see https://reui.io/preview/base/stats-12
* @see https://reui.io/preview/base/data-grid-filtering-2
* @see https://reui.io/preview/base/empty-state-12
*/
export const Route = createFileRoute('/_auth/network')({
component: NetworkComponent,
validateSearch: (search: Record<string, unknown>) => ({
tab: (search.tab === 'peers' || search.tab === 'speakers' || search.tab === 'control-plane'
? search.tab
: 'overview') as 'overview' | 'peers' | 'speakers' | 'control-plane',
tab: parseNetworkTab(search.tab),
}),
})
@@ -32,12 +42,10 @@ function NetworkComponent() {
const peersQ = useQuery({ ...networkPeersQueryOptions(), refetchInterval: 30_000 })
const speakersQ = useQuery({ ...networkSpeakersQueryOptions(), refetchInterval: 30_000 })
const birdQ = useQuery({ ...networkBirdQueryOptions(), refetchInterval: 30_000 })
const jobsQ = useQuery(overviewJobsQueryOptions())
const refreshing = peersQ.isFetching || speakersQ.isFetching
const refreshing = peersQ.isFetching || speakersQ.isFetching || birdQ.isFetching
const peers = peersQ.data?.items ?? []
const speakers = speakersQ.data?.items ?? []
const jobs = jobsQ.data?.items ?? []
const overviewLoading = peersQ.isLoading || speakersQ.isLoading
function refetchAll() {
@@ -47,121 +55,56 @@ function NetworkComponent() {
}
return (
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-4 md:gap-6">
<PageHeader
title="Сеть"
description="BGP-пиры, спикеры и live-метрики нод"
description="BGP-пиры, спикеры и статус BIRD"
actions={
<Button variant="outline" size="sm" onClick={refetchAll} disabled={refreshing}>
<RefreshCw className={refreshing ? 'animate-spin' : ''} />
<RefreshCw className={refreshing ? 'animate-spin' : undefined} />
Обновить
</Button>
}
/>
<BadgeTabs
<NetworkKpi
peers={peers}
speakers={speakers}
bird={birdQ.data}
loading={overviewLoading || birdQ.isLoading}
/>
<CountedLineTabs
value={search.tab}
onValueChange={(tab) =>
navigate({
search: {
tab: tab as 'overview' | 'peers' | 'speakers' | 'control-plane',
},
})
navigate({ search: { tab: tab as NetworkTab } })
}
items={[
{ value: 'overview', label: 'Обзор' },
{ value: 'peers', label: 'Пиры', count: peers.length },
{ value: 'speakers', label: 'Спикеры', count: speakers.length, badgeVariant: 'info-light' },
{ value: 'control-plane', label: 'Плоскость управления' },
tabs={[
{ id: 'peers', label: 'Пиры', count: peers.length },
{ id: 'speakers', label: 'Спикеры', count: speakers.length },
]}
>
<TabsContent value="overview" className="mt-0">
<div className="grid gap-4 lg:grid-cols-2">
<NetworkOverviewAnalyticsCard
peers={peers}
speakers={speakers}
loading={overviewLoading}
/>
<DashboardNetworkCapacityCard
peers={peers}
speakers={speakers}
jobs={jobs}
loading={overviewLoading}
/>
</div>
<PanelCard
className="mt-4"
title="BIRD (control plane)"
description="Статус birdc на хосте API"
contentClassName="py-4"
>
<QueryState
data={birdQ.data}
isLoading={birdQ.isLoading}
isError={birdQ.isError}
error={birdQ.error}
skeleton={<TableSkeleton rows={3} cols={2} />}
onRetry={() => birdQ.refetch()}
>
{(bird) => <BirdSummary bird={bird} />}
</QueryState>
</PanelCard>
</TabsContent>
<TabsContent value="peers" className="mt-0">
<TabsContent value="peers" className="mt-4">
<NetworkPeersCard
items={peers}
speakers={speakers}
isLoading={peersQ.isLoading}
isError={peersQ.isError}
error={peersQ.error}
onRetry={() => peersQ.refetch()}
onRetry={() => void peersQ.refetch()}
/>
</TabsContent>
<TabsContent value="speakers" className="mt-0">
<TabsContent value="speakers" className="mt-4">
<NetworkSpeakersCard
items={speakers}
isLoading={speakersQ.isLoading}
isError={speakersQ.isError}
error={speakersQ.error}
onRetry={() => speakersQ.refetch()}
onRetry={() => void speakersQ.refetch()}
/>
</TabsContent>
<TabsContent value="control-plane" className="mt-0">
<PanelCard
title="Настройки Control Plane (BIRD)"
description="Конфигурация tenant-level — в разделе «Настройки BIRD»"
contentClassName="py-4 text-sm text-muted-foreground"
>
См. раздел «Настройки BIRD».
</PanelCard>
</TabsContent>
</BadgeTabs>
</div>
)
}
function Field({ label, value }: { label: string; value: string }) {
return (
<div className="flex flex-col gap-0.5">
<span className="text-xs text-muted-foreground">{label}</span>
<span className="font-medium tabular-nums">{value}</span>
</div>
)
}
function BirdSummary({ bird }: { bird: import('@/types/api').BirdStatus }) {
return (
<div className="flex flex-col gap-2 text-sm">
<Field
label="Состояние"
value={bird.healthy === true ? 'В норме' : bird.healthy === false ? 'Проблема' : 'Н/Д'}
/>
<Field label="Сессий BGP" value={`${bird.bgp_established} / ${bird.bgp_sessions_total}`} />
{bird.message ? <p className="text-xs text-muted-foreground">{bird.message}</p> : null}
{bird.error ? <p className="text-xs text-destructive">{bird.error}</p> : null}
</CountedLineTabs>
</div>
)
}
+83
View File
@@ -0,0 +1,83 @@
import { createFileRoute, redirect } from '@tanstack/react-router'
import {
authPortalUrl,
clearPortalHandoffFlag,
clearPortalToken,
ensureAuthConfig,
firstAllowedPath,
getClaims,
getPortalToken,
parseHashToken,
redirectToPortalLogin,
setPortalToken,
} from '@/lib/auth'
/**
* SSO callback — reads `#access_token=…&expires_at=…` returned by auth-portal,
* stores the JWT, and drops the user on the first allowed page.
*
* If the hash is empty (direct visit / already logged in) the route re-runs the
* portal handshake; the cool-down guard prevents redirect storms.
*/
export const Route = createFileRoute('/auth/callback')({
validateSearch: (search: Record<string, unknown>) => ({
error: typeof search.error === 'string' ? search.error : undefined,
}),
beforeLoad: async ({ search }) => {
await ensureAuthConfig()
if (search.error === 'sso_loop') return
if (typeof window === 'undefined') return
const { accessToken } = parseHashToken(window.location.hash)
if (accessToken) {
setPortalToken(accessToken)
clearPortalHandoffFlag()
const claims = getClaims()
if (!claims) {
clearPortalToken()
window.location.assign(authPortalUrl())
await new Promise(() => {})
return
}
throw redirect({ to: firstAllowedPath() as '/dashboard' })
}
if (getPortalToken() && getClaims()) {
clearPortalHandoffFlag()
throw redirect({ to: firstAllowedPath() as '/dashboard' })
}
const ok = redirectToPortalLogin(`${window.location.origin}/auth/callback`)
if (!ok) {
throw redirect({
to: '/auth/callback',
search: { error: 'sso_loop' },
})
}
await new Promise(() => {})
},
component: AuthCallbackPage,
})
function AuthCallbackPage() {
const { error } = Route.useSearch()
if (error === 'sso_loop') {
return (
<div className="flex min-h-svh flex-col items-center justify-center gap-3 p-6 text-center">
<h1 className="text-lg font-semibold">Сессия не принята</h1>
<p className="text-muted-foreground max-w-md text-sm">
Повторный вход через auth-portal остановлен (защита от цикла редиректов).
Обычно это несовпадение JWT_SECRET / ISSUER или просроченный токен.
Войдите заново на portal, затем откройте EvoBGP.
</p>
<a className="text-primary text-sm underline" href={authPortalUrl()}>
Открыть Auth Portal
</a>
</div>
)
}
return null
}
+4
View File
@@ -2,6 +2,10 @@
interface ImportMetaEnv {
readonly VITE_APP_SWITCHER?: string
/** '1' | 'true' → require auth-portal JWT; иначе — локальный API-токен. */
readonly VITE_AUTH_ENABLED?: string
/** URL auth-portal (SSO). Пример: http://192.168.100.67:5175 или https://auth.shnt.top. */
readonly VITE_AUTH_PORTAL_URL?: string
}
interface ImportMeta {
File diff suppressed because one or more lines are too long
+30
View File
@@ -37,6 +37,11 @@ func main() {
BundleSeedHex: strings.TrimSpace(os.Getenv("EVOBGP_BUNDLE_SEED_HEX")),
CORSAllowedOrigins: strings.TrimSpace(os.Getenv("EVOBGP_CORS_ORIGINS")),
RuntimeLogsPolicyTenant: cfg.RuntimeLogsPolicyTenant,
JWTSecret: firstNonEmpty(os.Getenv("EVOBGP_AUTH_JWT_SECRET"), os.Getenv("AUTH_JWT_SECRET")),
AuthIssuer: firstNonEmpty(os.Getenv("EVOBGP_AUTH_ISSUER"), os.Getenv("AUTH_ISSUER")),
AuthPortalURL: firstNonEmpty(os.Getenv("EVOBGP_AUTH_PORTAL_URL"), os.Getenv("AUTH_PORTAL_URL")),
PortalTenantID: strings.TrimSpace(os.Getenv("EVOBGP_PORTAL_TENANT_ID")),
AuthRequired: boolFromEnv("EVOBGP_AUTH_REQUIRED", "AUTH_REQUIRED"),
}
srv, err := httpapi.New(opts)
if err != nil {
@@ -86,6 +91,31 @@ func main() {
log.Printf("%s stopped", platform.ServiceName("evobgp-all"))
}
func firstNonEmpty(candidates ...string) string {
for _, c := range candidates {
if v := strings.TrimSpace(c); v != "" {
return v
}
}
return ""
}
func boolFromEnv(keys ...string) bool {
for _, k := range keys {
v := strings.TrimSpace(os.Getenv(k))
if v == "" {
continue
}
switch strings.ToLower(v) {
case "1", "true", "yes", "on":
return true
case "0", "false", "no", "off":
return false
}
}
return false
}
func startBirdMetricsPoller(ctx context.Context) {
sock := strings.TrimSpace(os.Getenv("EVOBGP_BIRDC_SOCKET"))
if sock == "" {
+30
View File
@@ -32,6 +32,11 @@ func main() {
SeedDemo: seedDemo,
BundleSeedHex: strings.TrimSpace(os.Getenv("EVOBGP_BUNDLE_SEED_HEX")),
CORSAllowedOrigins: strings.TrimSpace(os.Getenv("EVOBGP_CORS_ORIGINS")),
JWTSecret: firstNonEmpty(os.Getenv("EVOBGP_AUTH_JWT_SECRET"), os.Getenv("AUTH_JWT_SECRET")),
AuthIssuer: firstNonEmpty(os.Getenv("EVOBGP_AUTH_ISSUER"), os.Getenv("AUTH_ISSUER")),
AuthPortalURL: firstNonEmpty(os.Getenv("EVOBGP_AUTH_PORTAL_URL"), os.Getenv("AUTH_PORTAL_URL")),
PortalTenantID: strings.TrimSpace(os.Getenv("EVOBGP_PORTAL_TENANT_ID")),
AuthRequired: boolFromEnv("EVOBGP_AUTH_REQUIRED", "AUTH_REQUIRED"),
}
srv, err := httpapi.New(opts)
if err != nil {
@@ -78,6 +83,31 @@ func main() {
log.Printf("%s stopped", platform.ServiceName("evobgp-api"))
}
func firstNonEmpty(candidates ...string) string {
for _, c := range candidates {
if v := strings.TrimSpace(c); v != "" {
return v
}
}
return ""
}
func boolFromEnv(keys ...string) bool {
for _, k := range keys {
v := strings.TrimSpace(os.Getenv(k))
if v == "" {
continue
}
switch strings.ToLower(v) {
case "1", "true", "yes", "on":
return true
case "0", "false", "no", "off":
return false
}
}
return false
}
func startBirdMetricsPoller(ctx context.Context) {
sock := strings.TrimSpace(os.Getenv("EVOBGP_BIRDC_SOCKET"))
if sock == "" {
+18
View File
@@ -2,6 +2,24 @@
Как выдавать доступ к control plane API, веб-клиентам и репликам BIRD (`evobgp-node`). Секреты храните в менеджере секретов, переменных окружения оркестратора или зашифрованных файлах — не коммитьте реальные ключи в Git.
## Portal SSO (JWT)
Единый вход через **auth-portal** (app id `bgp`). См. [integrate-evobgp.md](https://git.shts.su/denozord/auth-portal/src/branch/main/docs/integrate-evobgp.md) в репозитории auth-portal.
| Переменная | Назначение |
|------------|------------|
| `AUTH_REQUIRED` / `EVOBGP_AUTH_REQUIRED` | Включить проверку portal JWT для UI |
| `AUTH_JWT_SECRET` / `EVOBGP_AUTH_JWT_SECRET` | Тот же секрет, что `JWT_SECRET` портала (HS256) |
| `AUTH_ISSUER` | Issuer JWT (как на портале) |
| `AUTH_PORTAL_URL` | URL портала (также `GET /v1/auth/config`) |
| `EVOBGP_PORTAL_TENANT_ID` | Tenant для всех portal JWT (обязателен при JWT) |
Права — строки `bgp:<section>:<action>` из каталога портала (dashboard, modules, lookup, network, …). Apply/rollback требуют `bgp:operations:admin`.
**Ownership:** modules, peers, firewall clients/rules с `created_by_user_id` видны создателю и portal `is_admin` (API keys — весь tenant).
UI: `VITE_AUTH_ENABLED`, `VITE_AUTH_PORTAL_URL`. App Switcher: `CURRENT_APP_ID=bgp`, конфиг с `GET {portal}/api/v1/app-switcher`.
## API-ключи (`EVOBGP_API_KEYS`)
Формат переменной окружения: список записей через **запятую** без пробелов внутри логики парсера (пробелы вокруг записей допускаются при обрезке). Каждая запись:
+8 -2
View File
@@ -11,9 +11,15 @@ info:
Ошибки - `application/problem+json` ([RFC 9457](https://www.rfc-editor.org/rfc/rfc9457)).
Пагинация списков - `cursor` + `limit`; ответ содержит `items`, `next_cursor`, `has_more`.
**Роли** (матрица доступа): `viewer`, `editor`, `operator`, `node`. Нода использует отдельные пути и ключ с ролью `node`.
**Аутентификация (dual):**
- **API key** — `Authorization: Bearer <token>` из `EVOBGP_API_KEYS` / таблицы `api_key` (роли `viewer`/`editor`/`operator`/`node`/`firewall`).
- **Portal JWT** — HS256 от auth-portal; claim `apps` должен содержать `bgp`; права `bgp:<section>:<action>`; tenant из `EVOBGP_PORTAL_TENANT_ID`.
Публично: `GET /v1/auth/config` → `{ required, portal_url }`.
Заголовок `X-Tenant-Id` допускается только для супер-ролей (явный tenant); иначе tenant берётся из API-ключа.
**Роли API key** (матрица): `viewer`, `editor`, `operator`, `node`. Нода использует отдельные пути и ключ с ролью `node`.
JWT permissions мапятся на ту же лестницу (`:read`→viewer, `:write`→editor, `:admin`→operator).
Заголовок `X-Tenant-Id` допускается только для супер-ролей (явный tenant); иначе tenant берётся из API-ключа / portal tenant env.
license:
name: Proprietary
identifier: LicenseRef-Proprietary
+1 -1
View File
@@ -67,7 +67,7 @@ Gating: KV `ui_show_quick_actions` in `global_settings` via `PATCH /v1/settings`
Запрещено в chrome: `SidebarRail`, `NavUser` footer, sync-row footer, Search/Ctrl+K pill в header, issues Badge в header, muted/hover cascade на right-cluster, Provider `color-mix` для `--sidebar*`.
App Switcher ids: `vps-tracker` · `cfdm` · `evobgp`. Override: `VITE_APP_SWITCHER` JSON.
App Switcher: source of truth — auth-portal `GET /api/v1/app-switcher`. Id: `bgp`. Admin: portal `/admin/apps`.
QuickActionGrid icons: только semantic **text** (`text-info` / `text-primary` / …) на kit `bg-muted` — без solid `bg-primary` fills. Preview: [stats-12](https://reui.io/preview/base/stats-12).
+1
View File
@@ -14,6 +14,7 @@ require (
github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/golang-jwt/jwt/v5 v5.2.2 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
+2
View File
@@ -7,6 +7,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo=
+128 -7
View File
@@ -6,18 +6,32 @@ import (
"strings"
"evobgp/internal/authkey"
"github.com/golang-jwt/jwt/v5"
)
type ctxKey int
const authCtxKey ctxKey = 1
// Auth kinds distinguish API key sessions from portal JWT sessions.
const (
AuthKindAPIKey = "apikey"
AuthKindJWT = "jwt"
)
// Auth holds resolved API identity for a request.
type Auth struct {
TenantID string
Role string // viewer, editor, operator, node
Role string // viewer, editor, operator, node, firewall (apikeys only)
Token string
APIKeyID string // non-empty for DB-managed keys
// Portal / dual-auth fields (empty for API keys unless noted).
Kind string // "apikey" | "jwt"
UserID string // JWT sub
Email string // JWT email claim
Permissions []string // JWT permissions claim (bgp:*)
IsAdmin bool // JWT is_admin claim
}
func authFromContext(ctx context.Context) (Auth, bool) {
@@ -56,6 +70,23 @@ func parseAPIKeysSpec(spec string) []apiKeyRecord {
return out
}
// looksLikeJWT reports whether raw is a compact JWS (three dot-separated segments, non-empty).
func looksLikeJWT(raw string) bool {
if raw == "" {
return false
}
parts := strings.Split(raw, ".")
if len(parts) != 3 {
return false
}
for _, p := range parts {
if strings.TrimSpace(p) == "" {
return false
}
}
return true
}
func (s *Server) authMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h := r.Header.Get("Authorization")
@@ -65,6 +96,16 @@ func (s *Server) authMiddleware(next http.Handler) http.Handler {
return
}
raw := strings.TrimSpace(strings.TrimPrefix(h, p))
if looksLikeJWT(raw) && strings.TrimSpace(s.jwtSecret) != "" {
a, status, msg, ok := s.resolveJWT(raw)
if !ok {
writeProblem(w, status, http.StatusText(status), msg)
return
}
r = r.WithContext(context.WithValue(r.Context(), authCtxKey, a))
next.ServeHTTP(w, r)
return
}
a, ok := s.resolveAuth(raw)
if !ok {
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "unknown api key")
@@ -79,10 +120,16 @@ func (s *Server) authMiddleware(next http.Handler) http.Handler {
}
func authFromKeyRecord(raw string, rec apiKeyRecord) Auth {
return Auth{TenantID: rec.tenantID, Role: rec.role, Token: raw, APIKeyID: rec.keyID}
return Auth{
Kind: AuthKindAPIKey,
TenantID: rec.tenantID,
Role: rec.role,
Token: raw,
APIKeyID: rec.keyID,
}
}
// resolveAuth maps a bearer token to tenant identity.
// resolveAuth maps a bearer token to tenant identity (API key path).
// For the literal token "dev", the demo shortcut (devAuth) takes precedence when demo-seed
// is available; env/DB mapping is used only when demo tenant is absent.
func (s *Server) resolveAuth(raw string) (Auth, bool) {
@@ -99,23 +146,96 @@ func (s *Server) resolveAuth(raw string) (Auth, bool) {
if !ok {
if s.firewallResolver != nil {
if fw, ok := s.firewallResolver.Lookup(raw); ok {
return Auth{TenantID: fw.tenantID, Role: "firewall", Token: raw, APIKeyID: fw.clientID}, true
return Auth{Kind: AuthKindAPIKey, TenantID: fw.tenantID, Role: "firewall", Token: raw, APIKeyID: fw.clientID}, true
}
}
if client, err := s.store.LookupFirewallClientByTokenHash(authkey.HashToken(raw)); err == nil {
return Auth{TenantID: client.TenantID, Role: "firewall", Token: raw, APIKeyID: client.ID}, true
return Auth{Kind: AuthKindAPIKey, TenantID: client.TenantID, Role: "firewall", Token: raw, APIKeyID: client.ID}, true
}
return Auth{}, false
}
return authFromKeyRecord(raw, rec), true
}
// resolveJWT parses and validates a portal HS256 token, returning an Auth on success.
// Returns (auth, status, detail, ok). status/detail are used when ok=false.
func (s *Server) resolveJWT(raw string) (Auth, int, string, bool) {
if strings.TrimSpace(s.portalTenantID) == "" {
return Auth{}, http.StatusServiceUnavailable, "portal tenant not configured (EVOBGP_PORTAL_TENANT_ID)", false
}
tok, err := jwt.Parse(raw, func(t *jwt.Token) (any, error) {
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, jwt.ErrSignatureInvalid
}
return []byte(s.jwtSecret), nil
}, jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Alg()}))
if err != nil || tok == nil || !tok.Valid {
return Auth{}, http.StatusUnauthorized, "invalid jwt", false
}
claims, ok := tok.Claims.(jwt.MapClaims)
if !ok {
return Auth{}, http.StatusUnauthorized, "invalid jwt claims", false
}
if iss := strings.TrimSpace(s.authIssuer); iss != "" {
got, _ := claims["iss"].(string)
if strings.TrimSpace(got) != iss {
return Auth{}, http.StatusUnauthorized, "jwt issuer mismatch", false
}
}
apps := coerceStringSlice(claims["apps"])
if !containsFold(apps, "bgp") {
return Auth{}, http.StatusForbidden, "jwt does not grant access to bgp app", false
}
sub, _ := claims["sub"].(string)
if strings.TrimSpace(sub) == "" {
return Auth{}, http.StatusUnauthorized, "jwt missing sub", false
}
email, _ := claims["email"].(string)
perms := coerceStringSlice(claims["permissions"])
isAdmin, _ := claims["is_admin"].(bool)
return Auth{
Kind: AuthKindJWT,
TenantID: s.portalTenantID,
UserID: strings.TrimSpace(sub),
Email: strings.TrimSpace(email),
Permissions: perms,
IsAdmin: isAdmin,
Token: raw,
}, 0, "", true
}
func coerceStringSlice(v any) []string {
switch t := v.(type) {
case []string:
return t
case []any:
out := make([]string, 0, len(t))
for _, x := range t {
if s, ok := x.(string); ok {
out = append(out, s)
}
}
return out
default:
return nil
}
}
func containsFold(items []string, needle string) bool {
for _, x := range items {
if strings.EqualFold(strings.TrimSpace(x), needle) {
return true
}
}
return false
}
func (s *Server) devAuth() (Auth, bool) {
tid, _, _, _, _ := s.store.DemoIDs()
if tid == "" {
return Auth{}, false
}
return Auth{TenantID: tid, Role: "operator", Token: "dev"}, true
return Auth{Kind: AuthKindAPIKey, TenantID: tid, Role: "operator", Token: "dev"}, true
}
func roleLevel(role string) int {
@@ -131,7 +251,8 @@ func roleLevel(role string) int {
}
}
// requireAtLeast rejects node role and enforces viewer/editor/operator ladder.
// requireAtLeast rejects node/firewall roles and enforces viewer/editor/operator ladder for API keys.
// New code should call requirePerm which supports JWT permissions in addition to API-key roles.
func (s *Server) requireAtLeast(w http.ResponseWriter, a Auth, need string) bool {
if strings.ToLower(a.Role) == "node" {
writeProblem(w, http.StatusForbidden, "Forbidden", "node role cannot access this resource")
+225
View File
@@ -0,0 +1,225 @@
package httpapi
import (
"io"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/golang-jwt/jwt/v5"
)
const (
testJWTSecret = "test-secret-32-bytes-long-abcdef"
testIssuer = "https://auth.test.local"
)
func signTestJWT(t *testing.T, claims jwt.MapClaims) string {
t.Helper()
tok := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
s, err := tok.SignedString([]byte(testJWTSecret))
if err != nil {
t.Fatalf("sign jwt: %v", err)
}
return s
}
func newJWTTestServer(t *testing.T) (*Server, string) {
t.Helper()
srv, err := New(Options{
SeedDemo: true,
BundleSeedHex: testBundleSeed,
JWTSecret: testJWTSecret,
AuthIssuer: testIssuer,
AuthPortalURL: "https://portal.test.local",
AuthRequired: true,
PortalTenantID: "", // filled after DemoIDs
})
if err != nil {
t.Fatal(err)
}
tenant, _, _, _, _ := srv.Store().DemoIDs()
// Override tenant to match seed.
srv.portalTenantID = tenant
return srv, tenant
}
func TestAuthJWTAcceptedWithBGPApp(t *testing.T) {
srv, _ := newJWTTestServer(t)
defer srv.Close()
ts := httptest.NewServer(srv.Handler())
defer ts.Close()
token := signTestJWT(t, jwt.MapClaims{
"iss": testIssuer,
"sub": "user-1",
"email": "[email protected]",
"apps": []string{"bgp"},
"permissions": []string{"bgp:modules:read"},
"exp": time.Now().Add(time.Hour).Unix(),
})
req, _ := http.NewRequest(http.MethodGet, ts.URL+"/v1/modules", nil)
req.Header.Set("Authorization", "Bearer "+token)
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 TestAuthJWTRejectedOnWrongIssuer(t *testing.T) {
srv, _ := newJWTTestServer(t)
defer srv.Close()
ts := httptest.NewServer(srv.Handler())
defer ts.Close()
token := signTestJWT(t, jwt.MapClaims{
"iss": "https://other.example.com",
"sub": "user-1",
"apps": []string{"bgp"},
"exp": time.Now().Add(time.Hour).Unix(),
})
req, _ := http.NewRequest(http.MethodGet, ts.URL+"/v1/modules", nil)
req.Header.Set("Authorization", "Bearer "+token)
resp, err := ts.Client().Do(req)
if err != nil {
t.Fatal(err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusUnauthorized {
t.Fatalf("status=%d want 401", resp.StatusCode)
}
}
func TestAuthJWTRejectedWhenBGPAppMissing(t *testing.T) {
srv, _ := newJWTTestServer(t)
defer srv.Close()
ts := httptest.NewServer(srv.Handler())
defer ts.Close()
token := signTestJWT(t, jwt.MapClaims{
"iss": testIssuer,
"sub": "user-1",
"apps": []string{"cfdm", "portal"},
"exp": time.Now().Add(time.Hour).Unix(),
})
req, _ := http.NewRequest(http.MethodGet, ts.URL+"/v1/modules", nil)
req.Header.Set("Authorization", "Bearer "+token)
resp, err := ts.Client().Do(req)
if err != nil {
t.Fatal(err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusForbidden {
t.Fatalf("status=%d want 403", resp.StatusCode)
}
}
func TestAuthJWTIsAdminBypassesPermissions(t *testing.T) {
srv, _ := newJWTTestServer(t)
defer srv.Close()
ts := httptest.NewServer(srv.Handler())
defer ts.Close()
token := signTestJWT(t, jwt.MapClaims{
"iss": testIssuer,
"sub": "admin-1",
"apps": []string{"bgp"},
"is_admin": true,
"exp": time.Now().Add(time.Hour).Unix(),
})
req, _ := http.NewRequest(http.MethodGet, ts.URL+"/v1/api-keys", nil)
req.Header.Set("Authorization", "Bearer "+token)
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 TestAuthJWTMissingPermissionRejected(t *testing.T) {
srv, _ := newJWTTestServer(t)
defer srv.Close()
ts := httptest.NewServer(srv.Handler())
defer ts.Close()
token := signTestJWT(t, jwt.MapClaims{
"iss": testIssuer,
"sub": "user-1",
"apps": []string{"bgp"},
"permissions": []string{"bgp:modules:read"},
"exp": time.Now().Add(time.Hour).Unix(),
})
req, _ := http.NewRequest(http.MethodGet, ts.URL+"/v1/api-keys", nil)
req.Header.Set("Authorization", "Bearer "+token)
resp, err := ts.Client().Do(req)
if err != nil {
t.Fatal(err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusForbidden {
t.Fatalf("status=%d want 403", resp.StatusCode)
}
}
func TestAuthConfigPublic(t *testing.T) {
srv, _ := newJWTTestServer(t)
defer srv.Close()
ts := httptest.NewServer(srv.Handler())
defer ts.Close()
req, _ := http.NewRequest(http.MethodGet, ts.URL+"/v1/auth/config", nil)
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 TestHasPermissionSupersets(t *testing.T) {
cases := []struct {
name string
granted []string
want string
ok bool
}{
{"exact-read", []string{"bgp:modules:read"}, "bgp:modules:read", true},
{"write-covers-read", []string{"bgp:modules:write"}, "bgp:modules:read", true},
{"admin-covers-write", []string{"bgp:modules:admin"}, "bgp:modules:write", true},
{"read-does-not-cover-write", []string{"bgp:modules:read"}, "bgp:modules:write", false},
{"different-section", []string{"bgp:network:admin"}, "bgp:modules:read", false},
{"empty-granted", nil, "bgp:modules:read", false},
{"malformed-required", []string{"bgp:modules:admin"}, "bgp:modules", false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := HasPermission(tc.granted, tc.want); got != tc.ok {
t.Fatalf("HasPermission(%v, %q) = %v, want %v", tc.granted, tc.want, got, tc.ok)
}
})
}
}
+107
View File
@@ -0,0 +1,107 @@
package httpapi
import (
"net/http"
"strings"
)
// Portal permission strings. Format: <app>:<section>:<level> (bgp:modules:read).
// Superset order: admin ⊃ write ⊃ read for the same <app>:<section>.
const (
permLevelRead = "read"
permLevelWrite = "write"
permLevelAdmin = "admin"
)
// permLevelRank returns 0 for unknown, 1 for read, 2 for write, 3 for admin.
func permLevelRank(level string) int {
switch strings.ToLower(strings.TrimSpace(level)) {
case permLevelRead:
return 1
case permLevelWrite:
return 2
case permLevelAdmin:
return 3
default:
return 0
}
}
// splitPerm splits a permission string into (app, section, level).
func splitPerm(perm string) (app, section, level string, ok bool) {
parts := strings.Split(strings.TrimSpace(perm), ":")
if len(parts) != 3 {
return "", "", "", false
}
return strings.TrimSpace(parts[0]), strings.TrimSpace(parts[1]), strings.TrimSpace(parts[2]), true
}
// HasPermission reports whether the granted list satisfies required, applying the
// admin ⊃ write ⊃ read superset within the same app+section.
func HasPermission(granted []string, required string) bool {
rApp, rSection, rLevel, ok := splitPerm(required)
if !ok {
return false
}
needRank := permLevelRank(rLevel)
if needRank == 0 {
return false
}
for _, g := range granted {
gApp, gSection, gLevel, ok := splitPerm(g)
if !ok {
continue
}
if !strings.EqualFold(gApp, rApp) || !strings.EqualFold(gSection, rSection) {
continue
}
if permLevelRank(gLevel) >= needRank {
return true
}
}
return false
}
// permAPIKeyRoleFor maps a permission level to the API-key role required.
func permAPIKeyRoleFor(perm string) string {
_, _, level, ok := splitPerm(perm)
if !ok {
return "operator"
}
switch strings.ToLower(level) {
case permLevelRead:
return "viewer"
case permLevelWrite:
return "editor"
case permLevelAdmin:
return "operator"
default:
return "operator"
}
}
// requirePerm enforces a permission for a portal JWT or falls back to the API-key role ladder.
// node/firewall roles are always rejected (they use requireNode / requireFirewall).
func (s *Server) requirePerm(w http.ResponseWriter, a Auth, perm string) bool {
switch strings.ToLower(a.Role) {
case "node":
writeProblem(w, http.StatusForbidden, "Forbidden", "node role cannot access this resource")
return false
case "firewall":
writeProblem(w, http.StatusForbidden, "Forbidden", "firewall role cannot access this resource")
return false
}
if a.Kind == AuthKindJWT || len(a.Permissions) > 0 {
if a.IsAdmin || HasPermission(a.Permissions, perm) {
return true
}
writeProblem(w, http.StatusForbidden, "Forbidden", "missing permission: "+perm)
return false
}
need := permAPIKeyRoleFor(perm)
if roleLevel(a.Role) < roleLevel(need) {
writeProblem(w, http.StatusForbidden, "Forbidden", "insufficient role")
return false
}
return true
}
+39 -41
View File
@@ -35,6 +35,7 @@ func (s *Server) Handler() http.Handler {
s.mux.HandleFunc("GET /v1/health", s.handleHealth)
s.mux.HandleFunc("GET /v1/ready", s.handleReady)
s.mux.HandleFunc("GET /v1/version", s.handleVersion)
s.mux.HandleFunc("GET /v1/auth/config", s.handleAuthConfigPublic)
s.mux.HandleFunc("POST /v1/firewall/enroll", s.handleFirewallEnrollPublic)
s.mux.HandleFunc("GET /v1/firewall/install.sh", s.handleFirewallInstallScript)
s.mux.HandleFunc("GET /v1/firewall/sync-script", s.handleFirewallSyncScript)
@@ -93,6 +94,15 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
// handleAuthConfigPublic exposes portal-auth wiring so the UI can decide whether to redirect to the login portal.
// Registered on the public mux (no auth middleware): safe to call without a bearer token.
func (s *Server) handleAuthConfigPublic(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{
"required": s.authRequired,
"portal_url": s.authPortalURL,
})
}
func (s *Server) handleReady(w http.ResponseWriter, r *http.Request) {
checks := map[string]string{"store": "ok", "jobs": "memory"}
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
@@ -188,7 +198,7 @@ func (s *Server) handleListModules(w http.ResponseWriter, r *http.Request) {
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
return
}
if !s.requireAtLeast(w, a, "viewer") {
if !s.requirePerm(w, a, "bgp:modules:read") {
return
}
typeFilter := strings.TrimSpace(r.URL.Query().Get("type"))
@@ -204,23 +214,9 @@ func (s *Server) handleListModules(w http.ResponseWriter, r *http.Request) {
}
filtered := make([]*store.Module, 0)
limit := parseListLimit(r)
cursor := r.URL.Query().Get("cursor")
if typeFilter == "" && enabledFilter == nil {
page, next, more := s.store.ListModulesPage(a.TenantID, cursor, limit)
for _, mod := range page {
filtered = append(filtered, mod)
}
items := make([]map[string]any, 0, len(filtered))
for _, mod := range filtered {
items = append(items, moduleJSON(mod))
}
writeJSON(w, http.StatusOK, map[string]any{
"items": items, "next_cursor": strPtrOrNull(next), "has_more": more,
})
return
}
for _, mod := range s.store.ListModules(a.TenantID) {
all := s.store.ListModules(a.TenantID)
all = store.FilterOwned(all, func(m *store.Module) string { return m.CreatedByUserID }, a.Kind, a.IsAdmin, a.UserID)
for _, mod := range all {
if typeFilter != "" && mod.Type != typeFilter {
continue
}
@@ -245,7 +241,7 @@ func (s *Server) handleRouterListsCatalog(w http.ResponseWriter, r *http.Request
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
return
}
if !s.requireAtLeast(w, a, "viewer") {
if !s.requirePerm(w, a, "bgp:directories:read") {
return
}
cat, err := reports.BuildRouterListsCatalog(s.store, a.TenantID)
@@ -268,10 +264,14 @@ func (s *Server) handleGetModule(w http.ResponseWriter, r *http.Request) {
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
return
}
if !s.requireAtLeast(w, a, "viewer") {
if !s.requirePerm(w, a, "bgp:modules:read") {
return
}
mod, err := s.store.GetModule(a.TenantID, r.PathValue("module_id"))
if err == nil && !store.CanAccessOwned(a.Kind, a.IsAdmin, a.UserID, mod.CreatedByUserID) {
writeProblem(w, http.StatusNotFound, "Not Found", "module not found")
return
}
if err != nil {
if err == store.ErrNotFound || err == store.ErrTenantScope {
writeProblem(w, http.StatusNotFound, "Not Found", "module not found")
@@ -289,10 +289,11 @@ func (s *Server) handleListPeers(w http.ResponseWriter, r *http.Request) {
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
return
}
if !s.requireAtLeast(w, a, "viewer") {
if !s.requirePerm(w, a, "bgp:network:read") {
return
}
allPeers := s.store.ListPeers(a.TenantID)
allPeers = store.FilterOwned(allPeers, func(p *store.BGPPeer) string { return p.CreatedByUserID }, a.Kind, a.IsAdmin, a.UserID)
page, next, more := store.PaginateOffset(allPeers, r.URL.Query().Get("cursor"), parseListLimit(r))
fresh := r != nil && strings.EqualFold(strings.TrimSpace(r.URL.Query().Get("live")), "1")
ctx, cancel := context.WithTimeout(r.Context(), 12*time.Second)
@@ -392,7 +393,7 @@ func (s *Server) handleListSpeakers(w http.ResponseWriter, r *http.Request) {
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
return
}
if !s.requireAtLeast(w, a, "viewer") {
if !s.requirePerm(w, a, "bgp:network:read") {
return
}
speakers := s.store.ListSpeakersForTenant(a.TenantID)
@@ -424,7 +425,7 @@ func (s *Server) handleModuleRefresh(w http.ResponseWriter, r *http.Request) {
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
return
}
if !s.requireAtLeast(w, a, "editor") {
if !s.requirePerm(w, a, "bgp:modules:write") {
return
}
moduleID := r.PathValue("module_id")
@@ -460,7 +461,7 @@ func (s *Server) handleTenantRefresh(w http.ResponseWriter, r *http.Request) {
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
return
}
if !s.requireAtLeast(w, a, "editor") {
if !s.requirePerm(w, a, "bgp:modules:write") {
return
}
var body struct {
@@ -514,7 +515,7 @@ func (s *Server) handleListRevisions(w http.ResponseWriter, r *http.Request) {
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
return
}
if !s.requireAtLeast(w, a, "viewer") {
if !s.requirePerm(w, a, "bgp:operations:read") {
return
}
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
@@ -587,7 +588,7 @@ func (s *Server) handleGetRevision(w http.ResponseWriter, r *http.Request) {
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
return
}
if !s.requireAtLeast(w, a, "viewer") {
if !s.requirePerm(w, a, "bgp:operations:read") {
return
}
rev, err := s.store.GetRevisionSummary(a.TenantID, r.PathValue("revision_id"))
@@ -604,7 +605,7 @@ func (s *Server) handleRevisionPreview(w http.ResponseWriter, r *http.Request) {
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
return
}
if !s.requireAtLeast(w, a, "viewer") {
if !s.requirePerm(w, a, "bgp:operations:read") {
return
}
rev, err := s.store.GetRevision(a.TenantID, r.PathValue("revision_id"))
@@ -643,7 +644,7 @@ func (s *Server) handleRevisionDiagnosticLog(w http.ResponseWriter, r *http.Requ
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
return
}
if !s.requireAtLeast(w, a, "viewer") {
if !s.requirePerm(w, a, "bgp:operations:read") {
return
}
revID := r.PathValue("revision_id")
@@ -669,7 +670,7 @@ func (s *Server) handleRevisionDiff(w http.ResponseWriter, r *http.Request) {
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
return
}
if !s.requireAtLeast(w, a, "viewer") {
if !s.requirePerm(w, a, "bgp:operations:read") {
return
}
d, err := s.store.RevisionDiff(a.TenantID, r.PathValue("revision_a"), r.PathValue("revision_b"))
@@ -686,7 +687,7 @@ func (s *Server) handleRevisionRollback(w http.ResponseWriter, r *http.Request)
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
return
}
if !s.requireAtLeast(w, a, "operator") {
if !s.requirePerm(w, a, "bgp:operations:admin") {
return
}
revID := r.PathValue("revision_id")
@@ -717,8 +718,7 @@ func (s *Server) handleApply(w http.ResponseWriter, r *http.Request) {
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
return
}
if strings.ToLower(a.Role) != "operator" {
writeProblem(w, http.StatusForbidden, "Forbidden", "operator role required")
if !s.requirePerm(w, a, "bgp:operations:admin") {
return
}
var body struct {
@@ -764,8 +764,7 @@ func (s *Server) handleSpeakerApply(w http.ResponseWriter, r *http.Request) {
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
return
}
if strings.ToLower(a.Role) != "operator" {
writeProblem(w, http.StatusForbidden, "Forbidden", "operator role required")
if !s.requirePerm(w, a, "bgp:operations:admin") {
return
}
spkID := r.PathValue("id")
@@ -815,8 +814,7 @@ func (s *Server) handleBirdReload(w http.ResponseWriter, r *http.Request) {
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
return
}
if strings.ToLower(a.Role) != "operator" {
writeProblem(w, http.StatusForbidden, "Forbidden", "operator role required")
if !s.requirePerm(w, a, "bgp:operations:admin") {
return
}
idem := strings.TrimSpace(r.Header.Get("Idempotency-Key"))
@@ -839,7 +837,7 @@ func (s *Server) handleBirdStatus(w http.ResponseWriter, r *http.Request) {
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
return
}
if !s.requireAtLeast(w, a, "viewer") {
if !s.requirePerm(w, a, "bgp:monitoring:read") {
return
}
ctx, cancel := context.WithTimeout(r.Context(), 12*time.Second)
@@ -854,7 +852,7 @@ func (s *Server) handleListJobs(w http.ResponseWriter, r *http.Request) {
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
return
}
if !s.requireAtLeast(w, a, "viewer") {
if !s.requirePerm(w, a, "bgp:operations:read") {
return
}
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
@@ -877,7 +875,7 @@ func (s *Server) handleGetJob(w http.ResponseWriter, r *http.Request) {
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
return
}
if !s.requireAtLeast(w, a, "viewer") {
if !s.requirePerm(w, a, "bgp:operations:read") {
return
}
j, err := s.jobs.Get(a.TenantID, r.PathValue("job_id"))
@@ -894,7 +892,7 @@ func (s *Server) handleGetJobReport(w http.ResponseWriter, r *http.Request) {
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
return
}
if !s.requireAtLeast(w, a, "viewer") {
if !s.requirePerm(w, a, "bgp:operations:read") {
return
}
j, err := s.jobs.Get(a.TenantID, r.PathValue("job_id"))
@@ -932,7 +930,7 @@ func (s *Server) handleCancelJob(w http.ResponseWriter, r *http.Request) {
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
return
}
if !s.requireAtLeast(w, a, "editor") {
if !s.requirePerm(w, a, "bgp:operations:admin") {
return
}
j, err := s.jobs.RequestCancel(a.TenantID, r.PathValue("job_id"))
+18 -9
View File
@@ -21,13 +21,22 @@ func (s *Server) registerAPIKeyRoutes(m *http.ServeMux) {
func (s *Server) handleAuthSession(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "viewer") {
if !ok {
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
return
}
writeJSON(w, http.StatusOK, map[string]any{
resp := map[string]any{
"tenant_id": a.TenantID,
"role": a.Role,
})
"kind": a.Kind,
}
if a.Kind == AuthKindJWT {
resp["user_id"] = a.UserID
resp["email"] = a.Email
resp["permissions"] = a.Permissions
resp["is_admin"] = a.IsAdmin
}
writeJSON(w, http.StatusOK, resp)
}
func apiKeyJSON(k *store.APIKey) map[string]any {
@@ -59,7 +68,7 @@ func apiKeyJSON(k *store.APIKey) map[string]any {
func (s *Server) handleListAPIKeys(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "operator") {
if !ok || !s.requirePerm(w, a, "bgp:access:admin") {
return
}
list, err := s.store.ListAPIKeys(a.TenantID)
@@ -74,7 +83,7 @@ func (s *Server) handleListAPIKeys(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleGetAPIKey(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "operator") {
if !ok || !s.requirePerm(w, a, "bgp:access:admin") {
return
}
k, err := s.store.GetAPIKey(a.TenantID, r.PathValue("id"))
@@ -87,7 +96,7 @@ func (s *Server) handleGetAPIKey(w http.ResponseWriter, r *http.Request) {
func (s *Server) handlePostAPIKey(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "operator") {
if !ok || !s.requirePerm(w, a, "bgp:access:admin") {
return
}
var body struct {
@@ -127,7 +136,7 @@ func (s *Server) handlePostAPIKey(w http.ResponseWriter, r *http.Request) {
func (s *Server) handlePatchAPIKey(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "operator") {
if !ok || !s.requirePerm(w, a, "bgp:access:admin") {
return
}
var raw map[string]json.RawMessage
@@ -183,7 +192,7 @@ func (s *Server) handlePatchAPIKey(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleDeleteAPIKey(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "operator") {
if !ok || !s.requirePerm(w, a, "bgp:access:admin") {
return
}
if err := s.store.RevokeAPIKey(a.TenantID, r.PathValue("id")); err != nil {
@@ -199,7 +208,7 @@ func (s *Server) handleDeleteAPIKey(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleRotateAPIKey(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "operator") {
if !ok || !s.requirePerm(w, a, "bgp:access:admin") {
return
}
rotated, err := s.store.RotateAPIKey(a.TenantID, r.PathValue("id"))
+88 -49
View File
@@ -80,7 +80,7 @@ func (s *Server) registerCRUDRoutes(m *http.ServeMux) {
func (s *Server) handlePostModule(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "editor") {
if !ok || !s.requirePerm(w, a, "bgp:modules:write") {
return
}
var body struct {
@@ -99,12 +99,16 @@ func (s *Server) handlePostModule(w http.ResponseWriter, r *http.Request) {
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid json")
return
}
mod, err := s.store.CreateModule(a.TenantID, &store.Module{
newModule := &store.Module{
Type: body.Type, Name: body.Name, Enabled: body.Enabled, Priority: body.Priority,
RefreshIntervalSec: body.RefreshIntervalSec, CronExpr: body.CronExpr,
DefaultCommunityID: body.DefaultCommunityID, DohProfileID: body.DohProfileID,
DohProfileIDs: body.DohProfileIDs, DohResolverPolicy: body.DohResolverPolicy,
})
}
if a.Kind == AuthKindJWT && strings.TrimSpace(a.UserID) != "" {
newModule.CreatedByUserID = a.UserID
}
mod, err := s.store.CreateModule(a.TenantID, newModule)
if err != nil {
writeStoreErr(w, err)
return
@@ -114,7 +118,7 @@ func (s *Server) handlePostModule(w http.ResponseWriter, r *http.Request) {
func (s *Server) handlePatchModule(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "editor") {
if !ok || !s.requirePerm(w, a, "bgp:modules:write") {
return
}
rawBody, err := io.ReadAll(r.Body)
@@ -158,7 +162,14 @@ func (s *Server) handlePatchModule(w http.ResponseWriter, r *http.Request) {
body.RefreshIntervalSec = &zero
}
}
mod, err := s.store.UpdateModule(a.TenantID, r.PathValue("module_id"), &body)
moduleID := r.PathValue("module_id")
if existing, gerr := s.store.GetModule(a.TenantID, moduleID); gerr == nil {
if !store.CanAccessOwned(a.Kind, a.IsAdmin, a.UserID, existing.CreatedByUserID) {
writeProblem(w, http.StatusNotFound, "Not Found", "module not found")
return
}
}
mod, err := s.store.UpdateModule(a.TenantID, moduleID, &body)
if err != nil {
writeStoreErr(w, err)
return
@@ -168,10 +179,17 @@ func (s *Server) handlePatchModule(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleDeleteModule(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "editor") {
if !ok || !s.requirePerm(w, a, "bgp:modules:write") {
return
}
if err := s.store.SoftDeleteModule(a.TenantID, r.PathValue("module_id")); err != nil {
moduleID := r.PathValue("module_id")
if existing, gerr := s.store.GetModule(a.TenantID, moduleID); gerr == nil {
if !store.CanAccessOwned(a.Kind, a.IsAdmin, a.UserID, existing.CreatedByUserID) {
writeProblem(w, http.StatusNotFound, "Not Found", "module not found")
return
}
}
if err := s.store.SoftDeleteModule(a.TenantID, moduleID); err != nil {
writeStoreErr(w, err)
return
}
@@ -215,7 +233,7 @@ func writePostgresStoreErr(w http.ResponseWriter, err error) bool {
func (s *Server) handleListCDNSources(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "viewer") {
if !ok || !s.requirePerm(w, a, "bgp:modules:read") {
return
}
list, err := s.store.ListCDNSources(a.TenantID, r.PathValue("module_id"))
@@ -248,7 +266,7 @@ func cdnSourceJSON(x *store.CDNSource) map[string]any {
func (s *Server) handlePreviewCDNSource(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "editor") {
if !ok || !s.requirePerm(w, a, "bgp:modules:write") {
return
}
var body struct {
@@ -327,7 +345,7 @@ func (s *Server) handlePreviewCDNSource(w http.ResponseWriter, r *http.Request)
func (s *Server) handlePostCDNSource(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "editor") {
if !ok || !s.requirePerm(w, a, "bgp:modules:write") {
return
}
var body store.CDNSource
@@ -357,7 +375,7 @@ func (s *Server) handlePostCDNSource(w http.ResponseWriter, r *http.Request) {
func (s *Server) handlePatchCDNSource(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "editor") {
if !ok || !s.requirePerm(w, a, "bgp:modules:write") {
return
}
var body store.CDNSourcePatch
@@ -387,7 +405,7 @@ func (s *Server) handlePatchCDNSource(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleDeleteCDNSource(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "editor") {
if !ok || !s.requirePerm(w, a, "bgp:modules:write") {
return
}
mid := r.PathValue("module_id")
@@ -401,7 +419,7 @@ func (s *Server) handleDeleteCDNSource(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleListAS(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "viewer") {
if !ok || !s.requirePerm(w, a, "bgp:modules:read") {
return
}
list, err := s.store.ListASEntries(a.TenantID, r.PathValue("module_id"))
@@ -439,7 +457,7 @@ func asEntryJSON(x *store.ASEntry) map[string]any {
func (s *Server) handlePostAS(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "editor") {
if !ok || !s.requirePerm(w, a, "bgp:modules:write") {
return
}
var body store.ASEntry
@@ -459,7 +477,7 @@ func (s *Server) handlePostAS(w http.ResponseWriter, r *http.Request) {
func (s *Server) handlePatchAS(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "editor") {
if !ok || !s.requirePerm(w, a, "bgp:modules:write") {
return
}
var body store.ASEntryPatch
@@ -479,7 +497,7 @@ func (s *Server) handlePatchAS(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleDeleteAS(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "editor") {
if !ok || !s.requirePerm(w, a, "bgp:modules:write") {
return
}
mid := r.PathValue("module_id")
@@ -493,7 +511,7 @@ func (s *Server) handleDeleteAS(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleListDomain(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "viewer") {
if !ok || !s.requirePerm(w, a, "bgp:modules:read") {
return
}
list, err := s.store.ListDomainEntries(a.TenantID, r.PathValue("module_id"))
@@ -516,7 +534,7 @@ func domainEntryJSON(x *store.DomainEntry) map[string]any {
func (s *Server) handlePostDomain(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "editor") {
if !ok || !s.requirePerm(w, a, "bgp:modules:write") {
return
}
var body store.DomainEntry
@@ -536,7 +554,7 @@ func (s *Server) handlePostDomain(w http.ResponseWriter, r *http.Request) {
func (s *Server) handlePatchDomain(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "editor") {
if !ok || !s.requirePerm(w, a, "bgp:modules:write") {
return
}
var body store.DomainEntryPatch
@@ -556,7 +574,7 @@ func (s *Server) handlePatchDomain(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleDeleteDomain(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "editor") {
if !ok || !s.requirePerm(w, a, "bgp:modules:write") {
return
}
mid := r.PathValue("module_id")
@@ -570,7 +588,7 @@ func (s *Server) handleDeleteDomain(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleListIPRange(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "viewer") {
if !ok || !s.requirePerm(w, a, "bgp:modules:read") {
return
}
list, err := s.store.ListIPRangeEntries(a.TenantID, r.PathValue("module_id"))
@@ -593,7 +611,7 @@ func ipRangeJSON(x *store.IPRangeEntry) map[string]any {
func (s *Server) handlePostIPRange(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "editor") {
if !ok || !s.requirePerm(w, a, "bgp:modules:write") {
return
}
var body store.IPRangeEntry
@@ -613,7 +631,7 @@ func (s *Server) handlePostIPRange(w http.ResponseWriter, r *http.Request) {
func (s *Server) handlePatchIPRange(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "editor") {
if !ok || !s.requirePerm(w, a, "bgp:modules:write") {
return
}
var body store.IPRangePatch
@@ -633,7 +651,7 @@ func (s *Server) handlePatchIPRange(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleDeleteIPRange(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "editor") {
if !ok || !s.requirePerm(w, a, "bgp:modules:write") {
return
}
mid := r.PathValue("module_id")
@@ -647,7 +665,7 @@ func (s *Server) handleDeleteIPRange(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleExportModuleEntriesCSV(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "viewer") {
if !ok || !s.requirePerm(w, a, "bgp:modules:read") {
return
}
moduleID := r.PathValue("module_id")
@@ -730,7 +748,7 @@ func (s *Server) handleExportModuleEntriesCSV(w http.ResponseWriter, r *http.Req
func (s *Server) handleImportModuleEntriesCSV(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "editor") {
if !ok || !s.requirePerm(w, a, "bgp:modules:write") {
return
}
moduleID := r.PathValue("module_id")
@@ -774,7 +792,7 @@ func (s *Server) handleImportModuleEntriesCSV(w http.ResponseWriter, r *http.Req
func (s *Server) handleListDoh(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "viewer") {
if !ok || !s.requirePerm(w, a, "bgp:directories:read") {
return
}
list, err := s.store.ListDohProfiles(a.TenantID)
@@ -806,7 +824,7 @@ func dohJSON(x *store.DohProfile) map[string]any {
func (s *Server) handleGetDoh(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "viewer") {
if !ok || !s.requirePerm(w, a, "bgp:directories:read") {
return
}
x, err := s.store.GetDohProfile(a.TenantID, r.PathValue("id"))
@@ -819,7 +837,7 @@ func (s *Server) handleGetDoh(w http.ResponseWriter, r *http.Request) {
func (s *Server) handlePostDoh(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "editor") {
if !ok || !s.requirePerm(w, a, "bgp:directories:write") {
return
}
var body store.DohProfile
@@ -837,7 +855,7 @@ func (s *Server) handlePostDoh(w http.ResponseWriter, r *http.Request) {
func (s *Server) handlePatchDoh(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "editor") {
if !ok || !s.requirePerm(w, a, "bgp:directories:write") {
return
}
var body store.DohProfilePatch
@@ -855,7 +873,7 @@ func (s *Server) handlePatchDoh(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleDeleteDoh(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "editor") {
if !ok || !s.requirePerm(w, a, "bgp:directories:write") {
return
}
if err := s.store.DeleteDohProfile(a.TenantID, r.PathValue("id")); err != nil {
@@ -867,7 +885,7 @@ func (s *Server) handleDeleteDoh(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleListComm(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "viewer") {
if !ok || !s.requirePerm(w, a, "bgp:directories:read") {
return
}
list, err := s.store.ListCommunities(a.TenantID)
@@ -892,7 +910,7 @@ func commJSON(x *store.Community) map[string]any {
func (s *Server) handleGetComm(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "viewer") {
if !ok || !s.requirePerm(w, a, "bgp:directories:read") {
return
}
x, err := s.store.GetCommunity(a.TenantID, r.PathValue("id"))
@@ -905,7 +923,7 @@ func (s *Server) handleGetComm(w http.ResponseWriter, r *http.Request) {
func (s *Server) handlePostComm(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "editor") {
if !ok || !s.requirePerm(w, a, "bgp:directories:write") {
return
}
var body store.Community
@@ -923,7 +941,7 @@ func (s *Server) handlePostComm(w http.ResponseWriter, r *http.Request) {
func (s *Server) handlePatchComm(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "editor") {
if !ok || !s.requirePerm(w, a, "bgp:directories:write") {
return
}
var body store.CommunityPatch
@@ -941,7 +959,7 @@ func (s *Server) handlePatchComm(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleDeleteComm(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "editor") {
if !ok || !s.requirePerm(w, a, "bgp:directories:write") {
return
}
if err := s.store.DeleteCommunity(a.TenantID, r.PathValue("id")); err != nil {
@@ -953,7 +971,7 @@ func (s *Server) handleDeleteComm(w http.ResponseWriter, r *http.Request) {
func (s *Server) handlePostPeer(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "editor") {
if !ok || !s.requirePerm(w, a, "bgp:network:write") {
return
}
var body store.BGPPeer
@@ -962,6 +980,9 @@ func (s *Server) handlePostPeer(w http.ResponseWriter, r *http.Request) {
return
}
body.TenantID = a.TenantID
if a.Kind == AuthKindJWT && strings.TrimSpace(a.UserID) != "" {
body.CreatedByUserID = a.UserID
}
x, err := s.store.CreatePeer(a.TenantID, &body)
if err != nil {
writeStoreErr(w, err)
@@ -973,7 +994,7 @@ func (s *Server) handlePostPeer(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleGetPeer(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "viewer") {
if !ok || !s.requirePerm(w, a, "bgp:network:read") {
return
}
x, err := s.store.GetPeer(a.TenantID, r.PathValue("id"))
@@ -981,12 +1002,16 @@ func (s *Server) handleGetPeer(w http.ResponseWriter, r *http.Request) {
writeStoreErr(w, err)
return
}
if !store.CanAccessOwned(a.Kind, a.IsAdmin, a.UserID, x.CreatedByUserID) {
writeProblem(w, http.StatusNotFound, "Not Found", "peer not found")
return
}
writeJSON(w, http.StatusOK, peerJSON(x))
}
func (s *Server) handlePatchPeer(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "editor") {
if !ok || !s.requirePerm(w, a, "bgp:network:write") {
return
}
var body store.PeerPatch
@@ -994,7 +1019,14 @@ func (s *Server) handlePatchPeer(w http.ResponseWriter, r *http.Request) {
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid json")
return
}
x, err := s.store.UpdatePeer(a.TenantID, r.PathValue("id"), &body)
peerID := r.PathValue("id")
if existing, gerr := s.store.GetPeer(a.TenantID, peerID); gerr == nil {
if !store.CanAccessOwned(a.Kind, a.IsAdmin, a.UserID, existing.CreatedByUserID) {
writeProblem(w, http.StatusNotFound, "Not Found", "peer not found")
return
}
}
x, err := s.store.UpdatePeer(a.TenantID, peerID, &body)
if err != nil {
writeStoreErr(w, err)
return
@@ -1005,10 +1037,17 @@ func (s *Server) handlePatchPeer(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleDeletePeer(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "editor") {
if !ok || !s.requirePerm(w, a, "bgp:network:write") {
return
}
if err := s.store.DeletePeer(a.TenantID, r.PathValue("id")); err != nil {
peerID := r.PathValue("id")
if existing, gerr := s.store.GetPeer(a.TenantID, peerID); gerr == nil {
if !store.CanAccessOwned(a.Kind, a.IsAdmin, a.UserID, existing.CreatedByUserID) {
writeProblem(w, http.StatusNotFound, "Not Found", "peer not found")
return
}
}
if err := s.store.DeletePeer(a.TenantID, peerID); err != nil {
writeStoreErr(w, err)
return
}
@@ -1018,7 +1057,7 @@ func (s *Server) handleDeletePeer(w http.ResponseWriter, r *http.Request) {
func (s *Server) handlePostSpeaker(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "editor") {
if !ok || !s.requirePerm(w, a, "bgp:network:write") {
return
}
var body store.Speaker
@@ -1044,7 +1083,7 @@ func (s *Server) handlePostSpeaker(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleGetSpeakerByID(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "viewer") {
if !ok || !s.requirePerm(w, a, "bgp:network:read") {
return
}
x, err := s.store.GetSpeaker(a.TenantID, r.PathValue("speaker_id"))
@@ -1057,7 +1096,7 @@ func (s *Server) handleGetSpeakerByID(w http.ResponseWriter, r *http.Request) {
func (s *Server) handlePatchSpeaker(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "editor") {
if !ok || !s.requirePerm(w, a, "bgp:network:write") {
return
}
var body store.SpeakerPatch
@@ -1075,7 +1114,7 @@ func (s *Server) handlePatchSpeaker(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleDeleteSpeaker(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "editor") {
if !ok || !s.requirePerm(w, a, "bgp:network:write") {
return
}
if err := s.store.DeleteSpeaker(a.TenantID, r.PathValue("speaker_id")); err != nil {
@@ -1087,7 +1126,7 @@ func (s *Server) handleDeleteSpeaker(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleRevisionPrefixes(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "viewer") {
if !ok || !s.requirePerm(w, a, "bgp:operations:read") {
return
}
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
@@ -1111,7 +1150,7 @@ func (s *Server) handleRevisionPrefixes(w http.ResponseWriter, r *http.Request)
func (s *Server) handleGetSettings(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "viewer") {
if !ok || !s.requirePerm(w, a, "bgp:tenant_settings:admin") {
return
}
m, err := s.store.ListGlobalSettings(a.TenantID)
@@ -1124,7 +1163,7 @@ func (s *Server) handleGetSettings(w http.ResponseWriter, r *http.Request) {
func (s *Server) handlePatchSettings(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "operator") {
if !ok || !s.requirePerm(w, a, "bgp:tenant_settings:admin") {
return
}
var body map[string]any
+61 -15
View File
@@ -43,7 +43,7 @@ func (s *Server) registerFirewallRoutes(m *http.ServeMux) {
func (s *Server) handleFirewallInstallContext(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "operator") {
if !ok || !s.requirePerm(w, a, "bgp:firewall:write") {
return
}
seed := strings.TrimSpace(s.bundleSeedHex)
@@ -187,7 +187,7 @@ func readFirewallScript(name string) ([]byte, error) {
func (s *Server) handleListFirewallClients(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "viewer") {
if !ok || !s.requirePerm(w, a, "bgp:firewall:read") {
return
}
items, err := s.store.ListFirewallClients(a.TenantID)
@@ -195,12 +195,13 @@ func (s *Server) handleListFirewallClients(w http.ResponseWriter, r *http.Reques
writeInternalError(w, "internal", err)
return
}
items = store.FilterOwned(items, func(c *store.FirewallClient) string { return c.CreatedByUserID }, a.Kind, a.IsAdmin, a.UserID)
writeJSON(w, http.StatusOK, map[string]any{"items": items})
}
func (s *Server) handleGetFirewallClient(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "viewer") {
if !ok || !s.requirePerm(w, a, "bgp:firewall:read") {
return
}
id := strings.TrimSpace(r.PathValue("id"))
@@ -209,15 +210,25 @@ func (s *Server) handleGetFirewallClient(w http.ResponseWriter, r *http.Request)
writeProblem(w, http.StatusNotFound, "Not Found", "client not found")
return
}
if !store.CanAccessOwned(a.Kind, a.IsAdmin, a.UserID, client.CreatedByUserID) {
writeProblem(w, http.StatusNotFound, "Not Found", "client not found")
return
}
writeJSON(w, http.StatusOK, client)
}
func (s *Server) handlePatchFirewallClient(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "operator") {
if !ok || !s.requirePerm(w, a, "bgp:firewall:write") {
return
}
id := strings.TrimSpace(r.PathValue("id"))
if existing, gerr := s.store.GetFirewallClient(a.TenantID, id); gerr == nil {
if !store.CanAccessOwned(a.Kind, a.IsAdmin, a.UserID, existing.CreatedByUserID) {
writeProblem(w, http.StatusNotFound, "Not Found", "client not found")
return
}
}
var patch store.FirewallClientPatch
if err := json.NewDecoder(r.Body).Decode(&patch); err != nil {
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid JSON body")
@@ -233,10 +244,16 @@ func (s *Server) handlePatchFirewallClient(w http.ResponseWriter, r *http.Reques
func (s *Server) handleApproveFirewallClient(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "operator") {
if !ok || !s.requirePerm(w, a, "bgp:firewall:write") {
return
}
id := strings.TrimSpace(r.PathValue("id"))
if existing, gerr := s.store.GetFirewallClient(a.TenantID, id); gerr == nil {
if !store.CanAccessOwned(a.Kind, a.IsAdmin, a.UserID, existing.CreatedByUserID) {
writeProblem(w, http.StatusNotFound, "Not Found", "client not found")
return
}
}
client, err := s.store.ApproveFirewallClient(a.TenantID, id, a.APIKeyID)
if err != nil {
writeProblem(w, http.StatusNotFound, "Not Found", "client not found")
@@ -249,10 +266,16 @@ func (s *Server) handleApproveFirewallClient(w http.ResponseWriter, r *http.Requ
func (s *Server) handleRevokeFirewallClient(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "operator") {
if !ok || !s.requirePerm(w, a, "bgp:firewall:write") {
return
}
id := strings.TrimSpace(r.PathValue("id"))
if existing, gerr := s.store.GetFirewallClient(a.TenantID, id); gerr == nil {
if !store.CanAccessOwned(a.Kind, a.IsAdmin, a.UserID, existing.CreatedByUserID) {
writeProblem(w, http.StatusNotFound, "Not Found", "client not found")
return
}
}
if err := s.store.RevokeFirewallClient(a.TenantID, id); err != nil {
writeProblem(w, http.StatusNotFound, "Not Found", "client not found")
return
@@ -264,10 +287,16 @@ func (s *Server) handleRevokeFirewallClient(w http.ResponseWriter, r *http.Reque
func (s *Server) handleDeleteFirewallClient(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "operator") {
if !ok || !s.requirePerm(w, a, "bgp:firewall:write") {
return
}
id := strings.TrimSpace(r.PathValue("id"))
if existing, gerr := s.store.GetFirewallClient(a.TenantID, id); gerr == nil {
if !store.CanAccessOwned(a.Kind, a.IsAdmin, a.UserID, existing.CreatedByUserID) {
writeProblem(w, http.StatusNotFound, "Not Found", "client not found")
return
}
}
if err := s.store.DeleteFirewallClient(a.TenantID, id); err != nil {
writeProblem(w, http.StatusNotFound, "Not Found", "client not found")
return
@@ -279,7 +308,7 @@ func (s *Server) handleDeleteFirewallClient(w http.ResponseWriter, r *http.Reque
func (s *Server) handleListFirewallRules(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "viewer") {
if !ok || !s.requirePerm(w, a, "bgp:firewall:read") {
return
}
scope := strings.TrimSpace(r.URL.Query().Get("scope"))
@@ -297,12 +326,13 @@ func (s *Server) handleListFirewallRules(w http.ResponseWriter, r *http.Request)
writeInternalError(w, "internal", err)
return
}
items = store.FilterOwned(items, func(rule *store.FirewallRule) string { return rule.CreatedByUserID }, a.Kind, a.IsAdmin, a.UserID)
writeJSON(w, http.StatusOK, map[string]any{"items": items})
}
func (s *Server) handleCreateFirewallRule(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "operator") {
if !ok || !s.requirePerm(w, a, "bgp:firewall:write") {
return
}
var body struct {
@@ -326,12 +356,16 @@ func (s *Server) handleCreateFirewallRule(w http.ResponseWriter, r *http.Request
cid := strings.TrimSpace(*body.ClientID)
clientID = &cid
}
rule, err := s.store.CreateFirewallRule(a.TenantID, clientID, &store.FirewallRuleCreate{
fwRule := &store.FirewallRuleCreate{
Priority: body.Priority,
Action: body.Action,
CommunityID: body.CommunityID,
Comment: body.Comment,
})
}
if a.Kind == AuthKindJWT && strings.TrimSpace(a.UserID) != "" {
fwRule.CreatedByUserID = a.UserID
}
rule, err := s.store.CreateFirewallRule(a.TenantID, clientID, fwRule)
if err != nil {
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", "invalid rule")
return
@@ -342,10 +376,16 @@ func (s *Server) handleCreateFirewallRule(w http.ResponseWriter, r *http.Request
func (s *Server) handlePatchFirewallRule(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "operator") {
if !ok || !s.requirePerm(w, a, "bgp:firewall:write") {
return
}
id := strings.TrimSpace(r.PathValue("id"))
if existing, gerr := s.store.GetFirewallRule(a.TenantID, id); gerr == nil {
if !store.CanAccessOwned(a.Kind, a.IsAdmin, a.UserID, existing.CreatedByUserID) {
writeProblem(w, http.StatusNotFound, "Not Found", "rule not found")
return
}
}
var patch store.FirewallRulePatch
if err := json.NewDecoder(r.Body).Decode(&patch); err != nil {
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid JSON body")
@@ -362,10 +402,16 @@ func (s *Server) handlePatchFirewallRule(w http.ResponseWriter, r *http.Request)
func (s *Server) handleDeleteFirewallRule(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "operator") {
if !ok || !s.requirePerm(w, a, "bgp:firewall:write") {
return
}
id := strings.TrimSpace(r.PathValue("id"))
if existing, gerr := s.store.GetFirewallRule(a.TenantID, id); gerr == nil {
if !store.CanAccessOwned(a.Kind, a.IsAdmin, a.UserID, existing.CreatedByUserID) {
writeProblem(w, http.StatusNotFound, "Not Found", "rule not found")
return
}
}
if err := s.store.DeleteFirewallRule(a.TenantID, id); err != nil {
writeProblem(w, http.StatusNotFound, "Not Found", "rule not found")
return
@@ -376,7 +422,7 @@ func (s *Server) handleDeleteFirewallRule(w http.ResponseWriter, r *http.Request
func (s *Server) handleReorderFirewallRules(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "operator") {
if !ok || !s.requirePerm(w, a, "bgp:firewall:write") {
return
}
var body struct {
@@ -494,7 +540,7 @@ func (s *Server) handleFirewallHeartbeat(w http.ResponseWriter, r *http.Request)
func (s *Server) handleFirewallClientPreview(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "viewer") {
if !ok || !s.requirePerm(w, a, "bgp:firewall:read") {
return
}
id := strings.TrimSpace(r.PathValue("id"))
+1 -1
View File
@@ -16,7 +16,7 @@ func (s *Server) handleLookup(w http.ResponseWriter, r *http.Request) {
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
return
}
if !s.requireAtLeast(w, a, "viewer") {
if !s.requirePerm(w, a, "bgp:lookup:read") {
return
}
q := strings.TrimSpace(r.URL.Query().Get("q"))
+4 -4
View File
@@ -62,7 +62,7 @@ func maintenancePolicyJSON(p *store.MaintenancePolicy) map[string]any {
func (s *Server) handleListMaintenancePolicies(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "viewer") || !s.requirePostgres(w) {
if !ok || !s.requirePerm(w, a, "bgp:monitoring:read") || !s.requirePostgres(w) {
return
}
cursor := r.URL.Query().Get("cursor")
@@ -81,7 +81,7 @@ func (s *Server) handleListMaintenancePolicies(w http.ResponseWriter, r *http.Re
func (s *Server) handleGetMaintenancePolicy(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "viewer") || !s.requirePostgres(w) {
if !ok || !s.requirePerm(w, a, "bgp:monitoring:read") || !s.requirePostgres(w) {
return
}
p, err := s.store.GetMaintenancePolicy(r.PathValue("id"))
@@ -163,7 +163,7 @@ func (s *Server) handleDeleteMaintenancePolicy(w http.ResponseWriter, r *http.Re
func (s *Server) handleMaintenancePolicyHints(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "viewer") || !s.requirePostgres(w) {
if !ok || !s.requirePerm(w, a, "bgp:monitoring:read") || !s.requirePostgres(w) {
return
}
if s.maintStats == nil {
@@ -185,7 +185,7 @@ func (s *Server) handleMaintenancePolicyHints(w http.ResponseWriter, r *http.Req
func (s *Server) handleListMaintenanceConfigAudit(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "viewer") || !s.requirePostgres(w) {
if !ok || !s.requirePerm(w, a, "bgp:monitoring:read") || !s.requirePostgres(w) {
return
}
cursor := r.URL.Query().Get("cursor")
@@ -27,12 +27,10 @@ func (s *Server) registerPostgresMaintenanceRoutes(m *http.ServeMux) {
m.HandleFunc("GET /postgres/maintenance/logs", s.handlePostgresMaintenanceLogs)
}
// requireOperatorStrict is a compatibility shim mapping the legacy "operator"
// API-key role to the tenant-settings admin permission for JWT/API-key clients.
func (s *Server) requireOperatorStrict(w http.ResponseWriter, a Auth) bool {
if strings.ToLower(a.Role) != "operator" {
writeProblem(w, http.StatusForbidden, "Forbidden", "operator role required")
return false
}
return true
return s.requirePerm(w, a, "bgp:tenant_settings:admin")
}
func (s *Server) checkPgMaintRateLimit(tenantID, kind string) bool {
@@ -201,7 +199,7 @@ func (s *Server) handlePostgresCleanup(w http.ResponseWriter, r *http.Request) {
func (s *Server) handlePostgresMaintenanceLogs(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "viewer") || !s.requirePostgres(w) {
if !ok || !s.requirePerm(w, a, "bgp:monitoring:read") || !s.requirePostgres(w) {
return
}
cursor := r.URL.Query().Get("cursor")
@@ -35,7 +35,7 @@ func parseLimitQuery(r *http.Request, def, max int) int {
func (s *Server) handlePostgresOverview(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "viewer") || !s.requirePostgres(w) {
if !ok || !s.requirePerm(w, a, "bgp:monitoring:read") || !s.requirePostgres(w) {
return
}
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
@@ -50,7 +50,7 @@ func (s *Server) handlePostgresOverview(w http.ResponseWriter, r *http.Request)
func (s *Server) handlePostgresQueries(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "viewer") || !s.requirePostgres(w) {
if !ok || !s.requirePerm(w, a, "bgp:monitoring:read") || !s.requirePostgres(w) {
return
}
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
@@ -65,7 +65,7 @@ func (s *Server) handlePostgresQueries(w http.ResponseWriter, r *http.Request) {
func (s *Server) handlePostgresLocks(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "viewer") || !s.requirePostgres(w) {
if !ok || !s.requirePerm(w, a, "bgp:monitoring:read") || !s.requirePostgres(w) {
return
}
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
@@ -80,7 +80,7 @@ func (s *Server) handlePostgresLocks(w http.ResponseWriter, r *http.Request) {
func (s *Server) handlePostgresTables(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "viewer") || !s.requirePostgres(w) {
if !ok || !s.requirePerm(w, a, "bgp:monitoring:read") || !s.requirePostgres(w) {
return
}
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
@@ -95,7 +95,7 @@ func (s *Server) handlePostgresTables(w http.ResponseWriter, r *http.Request) {
func (s *Server) handlePostgresRecommendations(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "viewer") || !s.requirePostgres(w) {
if !ok || !s.requirePerm(w, a, "bgp:monitoring:read") || !s.requirePostgres(w) {
return
}
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
@@ -110,7 +110,7 @@ func (s *Server) handlePostgresRecommendations(w http.ResponseWriter, r *http.Re
func (s *Server) handleMonitoringCorrelation(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "viewer") || !s.requirePostgres(w) {
if !ok || !s.requirePerm(w, a, "bgp:monitoring:read") || !s.requirePostgres(w) {
return
}
window := 60
+2 -2
View File
@@ -52,7 +52,7 @@ func (s *Server) resolveRevisionRetentionMinutesBody(r *http.Request, tenantID s
func (s *Server) handleRevisionPruneEstimate(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "viewer") {
if !ok || !s.requirePerm(w, a, "bgp:operations:read") {
return
}
minutes, valid := s.resolveRevisionRetentionMinutesQuery(r, a.TenantID)
@@ -78,7 +78,7 @@ func (s *Server) handleRevisionPruneEstimate(w http.ResponseWriter, r *http.Requ
func (s *Server) handleRevisionPrune(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "operator") {
if !ok || !s.requirePerm(w, a, "bgp:operations:admin") {
return
}
minutes, valid := s.resolveRevisionRetentionMinutesBody(r, a.TenantID)
+6 -6
View File
@@ -70,7 +70,7 @@ func runtimeLogCleanupAuditJSON(row *store.RuntimeLogCleanupAudit) map[string]an
func (s *Server) handleListRuntimeLogFiles(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "viewer") || !s.requireRuntimeLogs(w) {
if !ok || !s.requirePerm(w, a, "bgp:monitoring:read") || !s.requireRuntimeLogs(w) {
return
}
items, err := s.runtimeLogs.ListFiles()
@@ -87,7 +87,7 @@ func (s *Server) handleListRuntimeLogFiles(w http.ResponseWriter, r *http.Reques
func (s *Server) handleGetRuntimeLogTail(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "viewer") || !s.requireRuntimeLogs(w) {
if !ok || !s.requirePerm(w, a, "bgp:monitoring:read") || !s.requireRuntimeLogs(w) {
return
}
filename := r.PathValue("filename")
@@ -111,7 +111,7 @@ func (s *Server) handleGetRuntimeLogTail(w http.ResponseWriter, r *http.Request)
func (s *Server) handleDeleteRuntimeLogFile(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "operator") || !s.requireRuntimeLogs(w) {
if !ok || !s.requirePerm(w, a, "bgp:tenant_settings:admin") || !s.requireRuntimeLogs(w) {
return
}
filename := r.PathValue("filename")
@@ -157,7 +157,7 @@ func (s *Server) runtimeLogAutoPolicy(w http.ResponseWriter, r *http.Request, te
func (s *Server) handleRuntimeLogAutoEstimate(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "operator") || !s.requireRuntimeLogs(w) {
if !ok || !s.requirePerm(w, a, "bgp:tenant_settings:admin") || !s.requireRuntimeLogs(w) {
return
}
policy, ok := s.runtimeLogAutoPolicy(w, r, a.TenantID)
@@ -199,7 +199,7 @@ func (s *Server) handleRuntimeLogAutoEstimate(w http.ResponseWriter, r *http.Req
func (s *Server) handleRuntimeLogAutoRun(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "operator") || !s.requireRuntimeLogs(w) {
if !ok || !s.requirePerm(w, a, "bgp:tenant_settings:admin") || !s.requireRuntimeLogs(w) {
return
}
policy, ok := s.runtimeLogAutoPolicy(w, r, a.TenantID)
@@ -221,7 +221,7 @@ func (s *Server) handleRuntimeLogAutoRun(w http.ResponseWriter, r *http.Request)
func (s *Server) handleListRuntimeLogCleanupAudit(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "viewer") {
if !ok || !s.requirePerm(w, a, "bgp:monitoring:read") {
return
}
cursor := r.URL.Query().Get("cursor")
+22
View File
@@ -36,6 +36,13 @@ type Server struct {
runtimeLogs *runtimelogs.Service
runtimeLogsPolicyTenant string
mux *http.ServeMux
// Portal / dual-auth (JWT) configuration.
jwtSecret string
authIssuer string
authPortalURL string
portalTenantID string
authRequired bool
}
// Options configures the API server.
@@ -49,6 +56,13 @@ type Options struct {
CORSAllowedOrigins string
// RuntimeLogsPolicyTenant overrides tenant for auto-cleanup scheduler settings (optional).
RuntimeLogsPolicyTenant string
// Portal / dual-auth (JWT) — leave empty to disable JWT path.
JWTSecret string // AUTH_JWT_SECRET / EVOBGP_AUTH_JWT_SECRET (HS256 shared secret)
AuthIssuer string // AUTH_ISSUER (expected iss claim; default https://auth.shnt.top)
AuthPortalURL string // AUTH_PORTAL_URL (returned by /v1/auth/config for the UI)
PortalTenantID string // EVOBGP_PORTAL_TENANT_ID (single tenant scope for JWT users)
AuthRequired bool // AUTH_REQUIRED / EVOBGP_AUTH_REQUIRED (surfaced via /v1/auth/config)
}
// New constructs Server and wiring for async jobs.
@@ -104,6 +118,14 @@ func New(opts Options) (*Server, error) {
cdnHTTP: NewCDNHTTPClient(),
runtimeLogs: runtimelogs.NewService(runtimelogs.ConfigFromEnv()),
runtimeLogsPolicyTenant: strings.TrimSpace(opts.RuntimeLogsPolicyTenant),
jwtSecret: strings.TrimSpace(opts.JWTSecret),
authIssuer: strings.TrimSpace(opts.AuthIssuer),
authPortalURL: strings.TrimSpace(opts.AuthPortalURL),
portalTenantID: strings.TrimSpace(opts.PortalTenantID),
authRequired: opts.AuthRequired,
}
if s.authIssuer == "" {
s.authIssuer = "https://auth.shnt.top"
}
s.mux = http.NewServeMux()
s.registerRoutes()
+1 -1
View File
@@ -71,7 +71,7 @@ func speakerJSONFromStore(st store.Backend, sp *store.Speaker) map[string]any {
func (s *Server) handleBundleSigningPublicKey(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "viewer") {
if !ok || !s.requirePerm(w, a, "bgp:network:read") {
return
}
writeJSON(w, http.StatusOK, map[string]any{
+46 -16
View File
@@ -119,7 +119,7 @@ func (p *Postgres) ListModules(tenantID string) []*store.Module {
ctx := context.Background()
rows, err := p.pool.Query(ctx, `
SELECT id, type, name, enabled, priority, doh_profile_id::text, doh_resolver_policy,
refresh_interval_sec, cron_expr, default_community_id::text, last_refreshed_at
refresh_interval_sec, cron_expr, default_community_id::text, last_refreshed_at, created_by_user_id
FROM module WHERE tenant_id = $1 AND deleted_at IS NULL ORDER BY priority, name`, tenantID)
if err != nil {
return nil
@@ -133,7 +133,8 @@ func (p *Postgres) ListModules(tenantID string) []*store.Module {
var doh, dc, cron *string
var refresh *int32
var last *time.Time
if err := rows.Scan(&m.ID, &m.Type, &m.Name, &m.Enabled, &m.Priority, &doh, &m.DohResolverPolicy, &refresh, &cron, &dc, &last); err != nil {
var createdBy *string
if err := rows.Scan(&m.ID, &m.Type, &m.Name, &m.Enabled, &m.Priority, &doh, &m.DohResolverPolicy, &refresh, &cron, &dc, &last, &createdBy); err != nil {
continue
}
m.DohResolverPolicy = store.NormalizeDohResolverPolicy(m.DohResolverPolicy)
@@ -153,6 +154,9 @@ func (p *Postgres) ListModules(tenantID string) []*store.Module {
t := last.UTC()
m.LastRefreshedAt = &t
}
if createdBy != nil {
m.CreatedByUserID = strings.TrimSpace(*createdBy)
}
out = append(out, &m)
moduleByID[m.ID] = &m
}
@@ -175,7 +179,7 @@ func (p *Postgres) ListModulesPage(tenantID, cursor string, limit int) ([]*store
ctx := context.Background()
rows, err := p.pool.Query(ctx, `
SELECT id, type, name, enabled, priority, doh_profile_id::text, doh_resolver_policy,
refresh_interval_sec, cron_expr, default_community_id::text, last_refreshed_at
refresh_interval_sec, cron_expr, default_community_id::text, last_refreshed_at, created_by_user_id
FROM module WHERE tenant_id = $1 AND deleted_at IS NULL
ORDER BY priority, name
LIMIT $2 OFFSET $3`, tenantID, limit+1, off)
@@ -191,7 +195,8 @@ func (p *Postgres) ListModulesPage(tenantID, cursor string, limit int) ([]*store
var doh, dc, cron *string
var refresh *int32
var last *time.Time
if err := rows.Scan(&m.ID, &m.Type, &m.Name, &m.Enabled, &m.Priority, &doh, &m.DohResolverPolicy, &refresh, &cron, &dc, &last); err != nil {
var createdBy *string
if err := rows.Scan(&m.ID, &m.Type, &m.Name, &m.Enabled, &m.Priority, &doh, &m.DohResolverPolicy, &refresh, &cron, &dc, &last, &createdBy); err != nil {
continue
}
m.DohResolverPolicy = store.NormalizeDohResolverPolicy(m.DohResolverPolicy)
@@ -211,6 +216,9 @@ func (p *Postgres) ListModulesPage(tenantID, cursor string, limit int) ([]*store
t := last.UTC()
m.LastRefreshedAt = &t
}
if createdBy != nil {
m.CreatedByUserID = strings.TrimSpace(*createdBy)
}
out = append(out, &m)
moduleByID[m.ID] = &m
}
@@ -238,11 +246,12 @@ func (p *Postgres) GetModule(tenantID, moduleID string) (*store.Module, error) {
var doh, dc, cron *string
var refresh *int32
var last *time.Time
var createdBy *string
err := p.pool.QueryRow(ctx, `
SELECT id, type, name, enabled, priority, doh_profile_id::text, doh_resolver_policy,
refresh_interval_sec, cron_expr, default_community_id::text, last_refreshed_at
refresh_interval_sec, cron_expr, default_community_id::text, last_refreshed_at, created_by_user_id
FROM module WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL`, moduleID, tenantID).Scan(
&m.ID, &m.Type, &m.Name, &m.Enabled, &m.Priority, &doh, &m.DohResolverPolicy, &refresh, &cron, &dc, &last)
&m.ID, &m.Type, &m.Name, &m.Enabled, &m.Priority, &doh, &m.DohResolverPolicy, &refresh, &cron, &dc, &last, &createdBy)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, store.ErrNotFound
@@ -265,6 +274,9 @@ func (p *Postgres) GetModule(tenantID, moduleID string) (*store.Module, error) {
t := last.UTC()
m.LastRefreshedAt = &t
}
if createdBy != nil {
m.CreatedByUserID = strings.TrimSpace(*createdBy)
}
m.DohResolverPolicy = store.NormalizeDohResolverPolicy(m.DohResolverPolicy)
if err := p.fillModuleDohFields(ctx, &m); err != nil {
return nil, err
@@ -299,10 +311,14 @@ func (p *Postgres) CreateModule(tenantID string, in *store.Module) (*store.Modul
lastArg = in.LastRefreshedAt.UTC()
}
policy := store.NormalizeDohResolverPolicy(in.DohResolverPolicy)
var createdBy any
if v := strings.TrimSpace(in.CreatedByUserID); v != "" {
createdBy = v
}
_, err := p.pool.Exec(ctx, `
INSERT INTO module (id, tenant_id, type, name, enabled, priority, doh_profile_id, doh_resolver_policy, refresh_interval_sec, cron_expr, default_community_id, last_refreshed_at)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12)`,
id, tenantID, in.Type, in.Name, in.Enabled, in.Priority, doh, policy, ri, cronArg, dc, lastArg)
INSERT INTO module (id, tenant_id, type, name, enabled, priority, doh_profile_id, doh_resolver_policy, refresh_interval_sec, cron_expr, default_community_id, last_refreshed_at, created_by_user_id)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)`,
id, tenantID, in.Type, in.Name, in.Enabled, in.Priority, doh, policy, ri, cronArg, dc, lastArg, createdBy)
if err != nil {
return nil, err
}
@@ -402,7 +418,8 @@ func (p *Postgres) ListPeers(tenantID string) []*store.BGPPeer {
ctx := context.Background()
rows, err := p.pool.Query(ctx, `
SELECT id::text, tenant_id::text, bgp_speaker_id::text, neighbor::text, remote_asn, enabled,
COALESCE(meta_json->>'name',''), COALESCE(meta_json->>'session_state',''), COALESCE(policies_json::text,'{}')
COALESCE(meta_json->>'name',''), COALESCE(meta_json->>'session_state',''), COALESCE(policies_json::text,'{}'),
created_by_user_id
FROM bgp_peer WHERE tenant_id=$1 ORDER BY neighbor`, tenantID)
if err != nil {
return nil
@@ -412,10 +429,14 @@ func (p *Postgres) ListPeers(tenantID string) []*store.BGPPeer {
for rows.Next() {
var peer store.BGPPeer
var sp *string
if err := rows.Scan(&peer.ID, &peer.TenantID, &sp, &peer.Neighbor, &peer.RemoteASN, &peer.Enabled, &peer.Name, &peer.SessionState, &peer.PoliciesJSON); err != nil {
var createdBy *string
if err := rows.Scan(&peer.ID, &peer.TenantID, &sp, &peer.Neighbor, &peer.RemoteASN, &peer.Enabled, &peer.Name, &peer.SessionState, &peer.PoliciesJSON, &createdBy); err != nil {
continue
}
peer.SpeakerID = sp
if createdBy != nil {
peer.CreatedByUserID = strings.TrimSpace(*createdBy)
}
out = append(out, &peer)
}
return out
@@ -425,11 +446,13 @@ func (p *Postgres) GetPeer(tenantID, id string) (*store.BGPPeer, error) {
ctx := context.Background()
var peer store.BGPPeer
var sp *string
var createdBy *string
err := p.pool.QueryRow(ctx, `
SELECT id::text, tenant_id::text, bgp_speaker_id::text, neighbor::text, remote_asn, enabled,
COALESCE(meta_json->>'name',''), COALESCE(meta_json->>'session_state',''), COALESCE(policies_json::text,'{}')
COALESCE(meta_json->>'name',''), COALESCE(meta_json->>'session_state',''), COALESCE(policies_json::text,'{}'),
created_by_user_id
FROM bgp_peer WHERE id=$1 AND tenant_id=$2`, id, tenantID).Scan(
&peer.ID, &peer.TenantID, &sp, &peer.Neighbor, &peer.RemoteASN, &peer.Enabled, &peer.Name, &peer.SessionState, &peer.PoliciesJSON)
&peer.ID, &peer.TenantID, &sp, &peer.Neighbor, &peer.RemoteASN, &peer.Enabled, &peer.Name, &peer.SessionState, &peer.PoliciesJSON, &createdBy)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, store.ErrNotFound
@@ -437,6 +460,9 @@ func (p *Postgres) GetPeer(tenantID, id string) (*store.BGPPeer, error) {
return nil, err
}
peer.SpeakerID = sp
if createdBy != nil {
peer.CreatedByUserID = strings.TrimSpace(*createdBy)
}
return &peer, nil
}
@@ -461,10 +487,14 @@ func (p *Postgres) CreatePeer(tenantID string, in *store.BGPPeer) (*store.BGPPee
sp = strings.TrimSpace(*in.SpeakerID)
}
enabled := store.EffectivePeerEnabledOnCreate(in.Enabled, in.SessionState)
var createdBy any
if v := strings.TrimSpace(in.CreatedByUserID); v != "" {
createdBy = v
}
_, err := p.pool.Exec(ctx, `
INSERT INTO bgp_peer (id, tenant_id, bgp_speaker_id, neighbor, remote_asn, enabled, policies_json, meta_json)
VALUES ($1,$2,$3,$4::inet, $5, $6, $7::jsonb, $8::jsonb)`,
id, tenantID, sp, neighbor, in.RemoteASN, enabled, pol, string(mb))
INSERT INTO bgp_peer (id, tenant_id, bgp_speaker_id, neighbor, remote_asn, enabled, policies_json, meta_json, created_by_user_id)
VALUES ($1,$2,$3,$4::inet, $5, $6, $7::jsonb, $8::jsonb, $9)`,
id, tenantID, sp, neighbor, in.RemoteASN, enabled, pol, string(mb), createdBy)
if err != nil {
return nil, err
}
+35 -21
View File
@@ -20,7 +20,7 @@ const firewallClientSelectCols = `
COALESCE(last_apply_prefix_count, 0), COALESCE(last_apply_ip_count, 0),
COALESCE(last_apply_packets_dropped, 0), COALESCE(last_apply_packets_accepted, 0),
COALESCE(last_apply_source, ''),
COALESCE(client_version, ''), created_at, approved_at, approved_by_api_key_id, revoked_at`
COALESCE(client_version, ''), created_at, approved_at, approved_by_api_key_id, revoked_at, created_by_user_id`
func (p *Postgres) ListFirewallClients(tenantID string) ([]*store.FirewallClient, error) {
ctx := context.Background()
@@ -63,11 +63,15 @@ func (p *Postgres) CreateFirewallClient(tenantID string, in *store.FirewallClien
}
id := uuid.NewString()
ctx := context.Background()
var createdBy any
if v := strings.TrimSpace(in.CreatedByUserID); v != "" {
createdBy = v
}
_, err := p.pool.Exec(ctx, `
INSERT INTO firewall_client (id, tenant_id, name, hostname, token_prefix, token_hash, client_version)
VALUES ($1,$2,$3,$4,$5,$6,$7)`,
INSERT INTO firewall_client (id, tenant_id, name, hostname, token_prefix, token_hash, client_version, created_by_user_id)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8)`,
id, tenantID, strings.TrimSpace(in.Name), strings.TrimSpace(in.Hostname),
in.TokenPrefix, in.TokenHash, strings.TrimSpace(in.ClientVersion))
in.TokenPrefix, in.TokenHash, strings.TrimSpace(in.ClientVersion), createdBy)
if err != nil {
return nil, err
}
@@ -232,11 +236,11 @@ func (p *Postgres) ListFirewallRules(tenantID string, clientID *string) ([]*stor
var err error
if clientID == nil {
rows, err = p.pool.Query(ctx, `
SELECT id, client_id, priority, action, community_id, comment, created_at, updated_at
SELECT id, client_id, priority, action, community_id, comment, created_at, updated_at, created_by_user_id
FROM firewall_rule WHERE tenant_id=$1 AND client_id IS NULL ORDER BY priority`, tenantID)
} else {
rows, err = p.pool.Query(ctx, `
SELECT id, client_id, priority, action, community_id, comment, created_at, updated_at
SELECT id, client_id, priority, action, community_id, comment, created_at, updated_at, created_by_user_id
FROM firewall_rule WHERE tenant_id=$1 AND client_id=$2 ORDER BY priority`, tenantID, *clientID)
}
if err != nil {
@@ -249,7 +253,7 @@ func (p *Postgres) ListFirewallRules(tenantID string, clientID *string) ([]*stor
func (p *Postgres) ListAllFirewallRulesForClient(tenantID, clientID string) ([]*store.FirewallRule, error) {
ctx := context.Background()
rows, err := p.pool.Query(ctx, `
SELECT id, client_id, priority, action, community_id, comment, created_at, updated_at
SELECT id, client_id, priority, action, community_id, comment, created_at, updated_at, created_by_user_id
FROM firewall_rule
WHERE tenant_id=$1 AND (client_id IS NULL OR client_id=$2)
ORDER BY CASE WHEN client_id IS NULL THEN 1 ELSE 0 END, priority`, tenantID, clientID)
@@ -263,7 +267,7 @@ func (p *Postgres) ListAllFirewallRulesForClient(tenantID, clientID string) ([]*
func (p *Postgres) ListAllFirewallRulesForReplication(tenantID string) ([]*store.FirewallRule, error) {
ctx := context.Background()
rows, err := p.pool.Query(ctx, `
SELECT id, client_id, priority, action, community_id, comment, created_at, updated_at
SELECT id, client_id, priority, action, community_id, comment, created_at, updated_at, created_by_user_id
FROM firewall_rule WHERE tenant_id=$1
ORDER BY CASE WHEN client_id IS NULL THEN 1 ELSE 0 END, client_id, priority`, tenantID)
if err != nil {
@@ -294,10 +298,14 @@ func (p *Postgres) CreateFirewallRule(tenantID string, clientID *string, in *sto
}
id := uuid.NewString()
ctx := context.Background()
var createdBy any
if v := strings.TrimSpace(in.CreatedByUserID); v != "" {
createdBy = v
}
_, err := p.pool.Exec(ctx, `
INSERT INTO firewall_rule (id, tenant_id, client_id, priority, action, community_id, comment)
VALUES ($1,$2,$3,$4,$5,$6,$7)`,
id, tenantID, clientID, priority, strings.ToLower(strings.TrimSpace(in.Action)), in.CommunityID, strings.TrimSpace(in.Comment))
INSERT INTO firewall_rule (id, tenant_id, client_id, priority, action, community_id, comment, created_by_user_id)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8)`,
id, tenantID, clientID, priority, strings.ToLower(strings.TrimSpace(in.Action)), in.CommunityID, strings.TrimSpace(in.Comment), createdBy)
if err != nil {
return nil, err
}
@@ -307,7 +315,7 @@ func (p *Postgres) CreateFirewallRule(tenantID string, clientID *string, in *sto
func (p *Postgres) GetFirewallRule(tenantID, id string) (*store.FirewallRule, error) {
ctx := context.Background()
row := p.pool.QueryRow(ctx, `
SELECT id, client_id, priority, action, community_id, comment, created_at, updated_at
SELECT id, client_id, priority, action, community_id, comment, created_at, updated_at, created_by_user_id
FROM firewall_rule WHERE id=$1 AND tenant_id=$2`, id, tenantID)
r, err := scanFirewallRuleRow(row.Scan, tenantID)
if err != nil {
@@ -423,19 +431,22 @@ func scanFirewallRules(rows pgx.Rows, tenantID string) ([]*store.FirewallRule, e
func scanFirewallRuleRow(scan scanFn, tenantID string) (*store.FirewallRule, error) {
var r store.FirewallRule
r.TenantID = tenantID
var clientID, communityID *string
if err := scan(&r.ID, &clientID, &r.Priority, &r.Action, &communityID, &r.Comment, &r.CreatedAt, &r.UpdatedAt); err != nil {
var clientID, communityID, createdBy *string
if err := scan(&r.ID, &clientID, &r.Priority, &r.Action, &communityID, &r.Comment, &r.CreatedAt, &r.UpdatedAt, &createdBy); err != nil {
return nil, err
}
r.ClientID = clientID
r.CommunityID = communityID
if createdBy != nil {
r.CreatedByUserID = strings.TrimSpace(*createdBy)
}
return &r, nil
}
func scanFirewallClientRow(scan scanFn, tenantID string) (*store.FirewallClient, error) {
var c store.FirewallClient
c.TenantID = tenantID
var approvedBy *string
var approvedBy, createdBy *string
var lastSeen, lastApply, approved, revoked *time.Time
var prefixCount, ipCount *int
var packetsDropped, packetsAccepted *int64
@@ -444,16 +455,16 @@ func scanFirewallClientRow(scan scanFn, tenantID string) (*store.FirewallClient,
&lastSeen, &c.LastSeenAtSource, &c.LastSeenIP,
&lastApply, &c.LastApplyStatus, &c.LastApplyError,
&prefixCount, &ipCount, &packetsDropped, &packetsAccepted, &c.LastApplySource,
&c.ClientVersion, &c.CreatedAt, &approved, &approvedBy, &revoked,
&c.ClientVersion, &c.CreatedAt, &approved, &approvedBy, &revoked, &createdBy,
); err != nil {
return nil, err
}
return finishFirewallClientScan(&c, lastSeen, lastApply, approved, revoked, approvedBy, prefixCount, ipCount, packetsDropped, packetsAccepted), nil
return finishFirewallClientScan(&c, lastSeen, lastApply, approved, revoked, approvedBy, createdBy, prefixCount, ipCount, packetsDropped, packetsAccepted), nil
}
func scanFirewallClientLookupRow(scan scanFn) (*store.FirewallClient, error) {
var c store.FirewallClient
var approvedBy *string
var approvedBy, createdBy *string
var lastSeen, lastApply, approved, revoked *time.Time
var prefixCount, ipCount *int
var packetsDropped, packetsAccepted *int64
@@ -462,14 +473,14 @@ func scanFirewallClientLookupRow(scan scanFn) (*store.FirewallClient, error) {
&lastSeen, &c.LastSeenAtSource, &c.LastSeenIP,
&lastApply, &c.LastApplyStatus, &c.LastApplyError,
&prefixCount, &ipCount, &packetsDropped, &packetsAccepted, &c.LastApplySource,
&c.ClientVersion, &c.CreatedAt, &approved, &approvedBy, &revoked,
&c.ClientVersion, &c.CreatedAt, &approved, &approvedBy, &revoked, &createdBy,
); err != nil {
return nil, err
}
return finishFirewallClientScan(&c, lastSeen, lastApply, approved, revoked, approvedBy, prefixCount, ipCount, packetsDropped, packetsAccepted), nil
return finishFirewallClientScan(&c, lastSeen, lastApply, approved, revoked, approvedBy, createdBy, prefixCount, ipCount, packetsDropped, packetsAccepted), nil
}
func finishFirewallClientScan(c *store.FirewallClient, lastSeen, lastApply, approved, revoked *time.Time, approvedBy *string, prefixCount, ipCount *int, packetsDropped, packetsAccepted *int64) *store.FirewallClient {
func finishFirewallClientScan(c *store.FirewallClient, lastSeen, lastApply, approved, revoked *time.Time, approvedBy, createdBy *string, prefixCount, ipCount *int, packetsDropped, packetsAccepted *int64) *store.FirewallClient {
c.LastSeenAt = lastSeen
c.LastApplyAt = lastApply
c.ApprovedAt = approved
@@ -477,6 +488,9 @@ func finishFirewallClientScan(c *store.FirewallClient, lastSeen, lastApply, appr
if approvedBy != nil {
c.ApprovedByAPIKeyID = *approvedBy
}
if createdBy != nil {
c.CreatedByUserID = strings.TrimSpace(*createdBy)
}
if prefixCount != nil {
c.LastApplyPrefixCount = *prefixCount
}
+1
View File
@@ -150,6 +150,7 @@ type Backend interface {
ListAllFirewallRulesForClient(tenantID, clientID string) ([]*FirewallRule, error)
ListAllFirewallRulesForReplication(tenantID string) ([]*FirewallRule, error)
CreateFirewallRule(tenantID string, clientID *string, in *FirewallRuleCreate) (*FirewallRule, error)
GetFirewallRule(tenantID, ruleID string) (*FirewallRule, error)
UpdateFirewallRule(tenantID, ruleID string, patch *FirewallRulePatch) (*FirewallRule, error)
DeleteFirewallRule(tenantID, ruleID string) error
ReorderFirewallRules(tenantID string, clientID *string, orderedIDs []string) error
+22 -18
View File
@@ -29,15 +29,17 @@ type FirewallClient struct {
ApprovedAt *time.Time `json:"approved_at,omitempty"`
ApprovedByAPIKeyID string `json:"approved_by_api_key_id,omitempty"`
RevokedAt *time.Time `json:"revoked_at,omitempty"`
CreatedByUserID string `json:"created_by_user_id,omitempty"`
}
// FirewallClientCreate is input for enroll (token hash supplied by caller).
type FirewallClientCreate struct {
Name string
Hostname string
TokenPrefix string
TokenHash []byte
ClientVersion string
Name string
Hostname string
TokenPrefix string
TokenHash []byte
ClientVersion string
CreatedByUserID string
}
// FirewallClientPatch is a partial update for operator edits.
@@ -62,23 +64,25 @@ type FirewallClientReplicationRow struct {
// FirewallRule is one block/accept policy rule.
type FirewallRule struct {
ID string `json:"id"`
TenantID string `json:"tenant_id,omitempty"`
ClientID *string `json:"client_id,omitempty"`
Priority int `json:"priority"`
Action string `json:"action"`
CommunityID *string `json:"community_id,omitempty"`
Comment string `json:"comment,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ID string `json:"id"`
TenantID string `json:"tenant_id,omitempty"`
ClientID *string `json:"client_id,omitempty"`
Priority int `json:"priority"`
Action string `json:"action"`
CommunityID *string `json:"community_id,omitempty"`
Comment string `json:"comment,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
CreatedByUserID string `json:"created_by_user_id,omitempty"`
}
// FirewallRuleCreate is input for creating a rule.
type FirewallRuleCreate struct {
Priority *int `json:"priority,omitempty"`
Action string `json:"action"`
CommunityID *string `json:"community_id,omitempty"`
Comment string `json:"comment,omitempty"`
Priority *int `json:"priority,omitempty"`
Action string `json:"action"`
CommunityID *string `json:"community_id,omitempty"`
Comment string `json:"comment,omitempty"`
CreatedByUserID string `json:"-"`
}
// FirewallRulePatch is a partial rule update.
+11 -9
View File
@@ -95,6 +95,7 @@ type Module struct {
DohResolverPolicy string
LastRefreshedAt *time.Time
DeletedAt *time.Time
CreatedByUserID string // portal JWT sub; empty = system / API key
}
type Revision struct {
@@ -112,15 +113,16 @@ type Revision struct {
// BGPPeer maps to bgp_peer (+ display fields in meta).
type BGPPeer struct {
ID string `json:"id,omitempty"`
TenantID string `json:"tenant_id,omitempty"`
SpeakerID *string `json:"bgp_speaker_id"`
Name string `json:"name"`
Neighbor string `json:"neighbor"`
RemoteASN int64 `json:"remote_asn"`
Enabled bool `json:"enabled"`
SessionState string `json:"session_state"`
PoliciesJSON string `json:"policies_json"`
ID string `json:"id,omitempty"`
TenantID string `json:"tenant_id,omitempty"`
SpeakerID *string `json:"bgp_speaker_id"`
Name string `json:"name"`
Neighbor string `json:"neighbor"`
RemoteASN int64 `json:"remote_asn"`
Enabled bool `json:"enabled"`
SessionState string `json:"session_state"`
PoliciesJSON string `json:"policies_json"`
CreatedByUserID string `json:"created_by_user_id,omitempty"`
}
type Speaker struct {
+2
View File
@@ -32,6 +32,7 @@ func (m *Memory) CreateModule(tenantID string, in *Module) (*Module, error) {
DohProfileIDs: append([]string(nil), in.DohProfileIDs...),
DohResolverPolicy: in.DohResolverPolicy,
LastRefreshedAt: in.LastRefreshedAt,
CreatedByUserID: strings.TrimSpace(in.CreatedByUserID),
}
NormalizeModuleDoh(mod)
m.modules[id] = mod
@@ -680,6 +681,7 @@ func (m *Memory) CreatePeer(tenantID string, in *BGPPeer) (*BGPPeer, error) {
Neighbor: neighbor, RemoteASN: in.RemoteASN,
Enabled: EffectivePeerEnabledOnCreate(in.Enabled, in.SessionState),
SessionState: in.SessionState, PoliciesJSON: in.PoliciesJSON,
CreatedByUserID: strings.TrimSpace(in.CreatedByUserID),
}
m.peers[id] = p
return p, nil
+29 -17
View File
@@ -49,14 +49,15 @@ func (m *Memory) CreateFirewallClient(tenantID string, in *FirewallClientCreate)
id := uuid.NewString()
rec := &firewallClientRec{
FirewallClient: FirewallClient{
ID: id,
TenantID: tenantID,
Name: strings.TrimSpace(in.Name),
Hostname: strings.TrimSpace(in.Hostname),
TokenPrefix: in.TokenPrefix,
Status: "pending",
ClientVersion: strings.TrimSpace(in.ClientVersion),
CreatedAt: now,
ID: id,
TenantID: tenantID,
Name: strings.TrimSpace(in.Name),
Hostname: strings.TrimSpace(in.Hostname),
TokenPrefix: in.TokenPrefix,
Status: "pending",
ClientVersion: strings.TrimSpace(in.ClientVersion),
CreatedAt: now,
CreatedByUserID: strings.TrimSpace(in.CreatedByUserID),
},
TokenHash: append([]byte(nil), in.TokenHash...),
}
@@ -311,20 +312,31 @@ func (m *Memory) CreateFirewallRule(tenantID string, clientID *string, in *Firew
now := time.Now().UTC()
id := uuid.NewString()
rule := &FirewallRule{
ID: id,
TenantID: tenantID,
ClientID: clientID,
Priority: priority,
Action: strings.ToLower(strings.TrimSpace(in.Action)),
CommunityID: in.CommunityID,
Comment: strings.TrimSpace(in.Comment),
CreatedAt: now,
UpdatedAt: now,
ID: id,
TenantID: tenantID,
ClientID: clientID,
Priority: priority,
Action: strings.ToLower(strings.TrimSpace(in.Action)),
CommunityID: in.CommunityID,
Comment: strings.TrimSpace(in.Comment),
CreatedAt: now,
UpdatedAt: now,
CreatedByUserID: strings.TrimSpace(in.CreatedByUserID),
}
m.firewallRules[id] = rule
return firewallRuleCopy(rule), nil
}
func (m *Memory) GetFirewallRule(tenantID, ruleID string) (*FirewallRule, error) {
m.mu.RLock()
defer m.mu.RUnlock()
rule, ok := m.firewallRules[ruleID]
if !ok || rule.TenantID != tenantID {
return nil, ErrNotFound
}
return firewallRuleCopy(rule), nil
}
func (m *Memory) UpdateFirewallRule(tenantID, ruleID string, patch *FirewallRulePatch) (*FirewallRule, error) {
if patch == nil {
return nil, ErrInvalidInput
+37
View File
@@ -0,0 +1,37 @@
package store
// Ownership helpers for portal JWT resource scoping.
// SeesAllOwned is true for API keys and portal admins (no per-user filter).
func SeesAllOwned(kind string, isAdmin bool) bool {
if kind != "jwt" {
return true
}
return isAdmin
}
// CanAccessOwned reports whether the actor may see/edit a resource with createdBy.
// Empty createdBy (legacy/API-key-created) is visible only when SeesAllOwned.
func CanAccessOwned(kind string, isAdmin bool, userID, createdBy string) bool {
if SeesAllOwned(kind, isAdmin) {
return true
}
if createdBy == "" {
return false
}
return createdBy == userID
}
// FilterOwnedStrings keeps items whose owner matches the actor.
func FilterOwned[T any](items []T, owner func(T) string, kind string, isAdmin bool, userID string) []T {
if SeesAllOwned(kind, isAdmin) {
return items
}
out := make([]T, 0, len(items))
for _, it := range items {
if CanAccessOwned(kind, isAdmin, userID, owner(it)) {
out = append(out, it)
}
}
return out
}
+67
View File
@@ -0,0 +1,67 @@
package store
import "testing"
func TestSeesAllOwned(t *testing.T) {
if !SeesAllOwned("apikey", false) {
t.Fatal("api keys must see all rows")
}
if !SeesAllOwned("jwt", true) {
t.Fatal("admin jwt must see all rows")
}
if SeesAllOwned("jwt", false) {
t.Fatal("non-admin jwt must not see all rows")
}
}
func TestCanAccessOwned(t *testing.T) {
if !CanAccessOwned("apikey", false, "", "someone") {
t.Fatal("api key must access any owner")
}
if !CanAccessOwned("jwt", true, "admin", "user-1") {
t.Fatal("admin jwt must access any owner")
}
if !CanAccessOwned("jwt", false, "user-1", "user-1") {
t.Fatal("owner must access their resource")
}
if CanAccessOwned("jwt", false, "user-1", "user-2") {
t.Fatal("non-owner must not access foreign resource")
}
if CanAccessOwned("jwt", false, "user-1", "") {
t.Fatal("non-admin jwt must not see legacy rows without owner")
}
}
type ownRow struct {
id string
owner string
}
func TestFilterOwned(t *testing.T) {
rows := []ownRow{
{"a", "user-1"},
{"b", "user-2"},
{"c", ""},
}
get := func(r ownRow) string { return r.owner }
got := FilterOwned(rows, get, "apikey", false, "")
if len(got) != 3 {
t.Fatalf("apikey filter: got=%d want 3", len(got))
}
got = FilterOwned(rows, get, "jwt", true, "any")
if len(got) != 3 {
t.Fatalf("admin jwt filter: got=%d want 3", len(got))
}
got = FilterOwned(rows, get, "jwt", false, "user-1")
if len(got) != 1 || got[0].id != "a" {
t.Fatalf("user-1 filter: got=%+v want [a]", got)
}
got = FilterOwned(rows, get, "jwt", false, "user-3")
if len(got) != 0 {
t.Fatalf("unknown user filter: got=%+v want []", got)
}
}
@@ -0,0 +1,9 @@
DROP INDEX IF EXISTS idx_firewall_rule_created_by;
DROP INDEX IF EXISTS idx_firewall_client_created_by;
DROP INDEX IF EXISTS idx_bgp_peer_created_by;
DROP INDEX IF EXISTS idx_module_created_by;
ALTER TABLE firewall_rule DROP COLUMN IF EXISTS created_by_user_id;
ALTER TABLE firewall_client DROP COLUMN IF EXISTS created_by_user_id;
ALTER TABLE bgp_peer DROP COLUMN IF EXISTS created_by_user_id;
ALTER TABLE module DROP COLUMN IF EXISTS created_by_user_id;
@@ -0,0 +1,10 @@
-- Ownership for portal JWT users (modules, peers, firewall).
ALTER TABLE module ADD COLUMN IF NOT EXISTS created_by_user_id TEXT;
ALTER TABLE bgp_peer ADD COLUMN IF NOT EXISTS created_by_user_id TEXT;
ALTER TABLE firewall_client ADD COLUMN IF NOT EXISTS created_by_user_id TEXT;
ALTER TABLE firewall_rule ADD COLUMN IF NOT EXISTS created_by_user_id TEXT;
CREATE INDEX IF NOT EXISTS idx_module_created_by ON module (tenant_id, created_by_user_id);
CREATE INDEX IF NOT EXISTS idx_bgp_peer_created_by ON bgp_peer (tenant_id, created_by_user_id);
CREATE INDEX IF NOT EXISTS idx_firewall_client_created_by ON firewall_client (tenant_id, created_by_user_id);
CREATE INDEX IF NOT EXISTS idx_firewall_rule_created_by ON firewall_rule (tenant_id, created_by_user_id);
@@ -0,0 +1,7 @@
DROP INDEX IF EXISTS idx_firewall_rule_created_by;
DROP INDEX IF EXISTS idx_firewall_client_created_by;
DROP INDEX IF EXISTS idx_bgp_peer_created_by;
DROP INDEX IF EXISTS idx_module_created_by;
-- SQLite: recreate tables without column is heavy; leave columns (no-op down for v1).
-- Down migration intentionally empty for SQLite ALTER DROP COLUMN compatibility.
@@ -0,0 +1,10 @@
-- Ownership for portal JWT users (modules, peers, firewall).
ALTER TABLE module ADD COLUMN created_by_user_id TEXT;
ALTER TABLE bgp_peer ADD COLUMN created_by_user_id TEXT;
ALTER TABLE firewall_client ADD COLUMN created_by_user_id TEXT;
ALTER TABLE firewall_rule ADD COLUMN created_by_user_id TEXT;
CREATE INDEX IF NOT EXISTS idx_module_created_by ON module (tenant_id, created_by_user_id);
CREATE INDEX IF NOT EXISTS idx_bgp_peer_created_by ON bgp_peer (tenant_id, created_by_user_id);
CREATE INDEX IF NOT EXISTS idx_firewall_client_created_by ON firewall_client (tenant_id, created_by_user_id);
CREATE INDEX IF NOT EXISTS idx_firewall_rule_created_by ON firewall_rule (tenant_id, created_by_user_id);