Compare commits

...
3 Commits
Author SHA1 Message Date
Denozordec b28ad88b22 feat(auth): enhance tenant resolution in JWT handling
CI / changes (push) Successful in 4s
CI / openapi (push) Successful in 25s
CI / web (push) Successful in 58s
CI / commitlint (push) Skipped
CI / go (push) Successful in 1m3s
CI / bird2 (push) Successful in 16s
CI / release (push) Successful in 4m15s
Updated the authentication logic to prioritize tenant resolution from JWT claims, specifically using `tenants.bgp` or `bgp_tenant_id` as the primary source. If these claims are absent, the system will fallback to the configured `EVOBGP_PORTAL_TENANT_ID`. This change improves the flexibility of tenant management in the authentication process. Additionally, updated related documentation to reflect these changes and added tests to ensure proper functionality.
2026-07-19 01:03:33 +07:00
Denozordec b871d62de6 feat(auth): add environment variables for portal SSO integration
Introduced new environment variables for the authentication portal in the production and microvps configurations. Updated the documentation to clarify the necessity of passing these variables to the evobgp-all service. This change enhances the authentication flow by enabling single sign-on (SSO) capabilities through JWT, ensuring a more secure and streamlined user experience.
2026-07-19 00:55:35 +07:00
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
52 changed files with 1983 additions and 254 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>
)
}
+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',
+304
View File
@@ -0,0 +1,304 @@
/**
* 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
if (claims.is_admin) return true
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,
+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 == "" {
+7
View File
@@ -15,3 +15,10 @@ WEBUI_DOMAIN=bgp.example.com
WEBUI_IP_WHITELIST=203.0.113.10/32
LETSENCRYPT_EMAIL=[email protected]
CF_DNS_API_TOKEN=
# Auth-portal SSO → контейнер evobgp-all (не VITE_* — они только для build web)
AUTH_REQUIRED=true
AUTH_JWT_SECRET=
AUTH_ISSUER=https://auth.shnt.top
AUTH_PORTAL_URL=https://auth.shnt.top
EVOBGP_PORTAL_TENANT_ID=
@@ -27,3 +27,16 @@ AUTO_UPDATE_INTERVAL_SEC=300
AUTO_UPDATE_SERVICES=evobgp-all,evobgp-web
# Защищенные сервисы, которые updater никогда не перезапускает
AUTO_UPDATE_PROTECTED_SERVICES=bird2
# ---------------------------------------------------------------------------
# Auth-portal SSO (прокидывается в контейнер evobgp-all)
# VITE_* в runtime .env НЕ влияют на уже собранный web-образ —
# UI читает GET /v1/auth/config с API (AUTH_REQUIRED / AUTH_PORTAL_URL).
# ---------------------------------------------------------------------------
AUTH_REQUIRED=true
AUTH_JWT_SECRET=
AUTH_ISSUER=https://auth.shnt.top
AUTH_PORTAL_URL=https://auth.shnt.top
# UUID tenant из БД (обязателен для JWT). При EVOBGP_SEED_DEMO=1 смотрите лог
# старта evobgp-all / SELECT id FROM tenant LIMIT 1;
EVOBGP_PORTAL_TENANT_ID=
@@ -136,6 +136,11 @@ services:
EVOBGP_BIRD_STAGING_DIR: /tmp/evobgp-bird-staging
EVOBGP_SERVICE: evobgp-all
EVOBGP_RUNTIME_LOGS_DIR: /opt/evobgp/runtime-logs
AUTH_REQUIRED: ${AUTH_REQUIRED:-false}
AUTH_JWT_SECRET: ${AUTH_JWT_SECRET:-}
AUTH_ISSUER: ${AUTH_ISSUER:-https://auth.shnt.top}
AUTH_PORTAL_URL: ${AUTH_PORTAL_URL:-}
EVOBGP_PORTAL_TENANT_ID: ${EVOBGP_PORTAL_TENANT_ID:-}
EVOBGP_DEV_INSECURE: "1"
volumes:
- bird_etc:/etc/bird
+6
View File
@@ -138,6 +138,12 @@ services:
EVOBGP_BIRD_STAGING_DIR: /tmp/evobgp-bird-staging
EVOBGP_SERVICE: evobgp-all
EVOBGP_RUNTIME_LOGS_DIR: /opt/evobgp/runtime-logs
# Portal SSO (JWT) — см. docs/access.md / auth-portal integrate-evobgp.md
AUTH_REQUIRED: ${AUTH_REQUIRED:-false}
AUTH_JWT_SECRET: ${AUTH_JWT_SECRET:-}
AUTH_ISSUER: ${AUTH_ISSUER:-https://auth.shnt.top}
AUTH_PORTAL_URL: ${AUTH_PORTAL_URL:-}
EVOBGP_PORTAL_TENANT_ID: ${EVOBGP_PORTAL_TENANT_ID:-}
# DEV ONLY — не для production (см. docs/access.md).
EVOBGP_DEV_INSECURE: "1"
volumes:
+34
View File
@@ -2,6 +2,40 @@
Как выдавать доступ к 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` | Fallback tenant для portal JWT, если в токене нет `bgp_tenant_id` / `tenants.bgp` |
Источник tenant (по приоритету):
1. JWT claim `tenants.bgp` или `bgp_tenant_id` (задаётся в auth-portal → **Админ → Приложения** → поле «EvoBGP tenant ID»)
2. Env `EVOBGP_PORTAL_TENANT_ID`
Compose: переменные `AUTH_*` / `EVOBGP_PORTAL_TENANT_ID` должны быть в `environment:` сервиса **`evobgp-all`** (см. `deploy/compose/stack.microvps-full.yaml`). Просто положить их в `.env` без проброса в контейнер недостаточно.
`VITE_AUTH_*` в runtime `.env` **не** меняют уже собранный `evobgp-web` образ. UI берёт режим из `GET /v1/auth/config` (`required``AUTH_REQUIRED`, `portal_url``AUTH_PORTAL_URL`).
Проверка после рестарта:
```bash
curl -sS https://bgp.shnt.top/v1/auth/config
# {"required":true,"portal_url":"https://auth.shnt.top"}
```
Права — строки `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 из `tenants.bgp` / `bgp_tenant_id` или fallback `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=
+147 -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,115 @@ 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) {
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
}
tenantID := tenantIDFromClaims(claims)
if tenantID == "" {
tenantID = strings.TrimSpace(s.portalTenantID)
}
if tenantID == "" {
return Auth{}, http.StatusServiceUnavailable, "portal tenant not configured (set bgp tenant in auth-portal App Switcher or EVOBGP_PORTAL_TENANT_ID)", false
}
email, _ := claims["email"].(string)
perms := coerceStringSlice(claims["permissions"])
isAdmin, _ := claims["is_admin"].(bool)
return Auth{
Kind: AuthKindJWT,
TenantID: tenantID,
UserID: strings.TrimSpace(sub),
Email: strings.TrimSpace(email),
Permissions: perms,
IsAdmin: isAdmin,
Token: raw,
}, 0, "", true
}
// tenantIDFromClaims prefers tenants.bgp, then bgp_tenant_id.
func tenantIDFromClaims(claims jwt.MapClaims) string {
if m, ok := claims["tenants"].(map[string]any); ok {
if v, ok := m["bgp"].(string); ok {
if tid := strings.TrimSpace(v); tid != "" {
return tid
}
}
}
if v, ok := claims["bgp_tenant_id"].(string); ok {
return strings.TrimSpace(v)
}
return ""
}
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 +270,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")
+302
View File
@@ -0,0 +1,302 @@
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 TestAuthJWTTenantFromClaimWithoutEnv(t *testing.T) {
srv, err := New(Options{
SeedDemo: true,
BundleSeedHex: testBundleSeed,
JWTSecret: testJWTSecret,
AuthIssuer: testIssuer,
AuthPortalURL: "https://portal.test.local",
AuthRequired: true,
// No PortalTenantID — must come from JWT claim.
})
if err != nil {
t.Fatal(err)
}
defer srv.Close()
tenant, _, _, _, _ := srv.Store().DemoIDs()
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"},
"bgp_tenant_id": tenant,
"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 TestAuthJWTRejectedWhenTenantMissing(t *testing.T) {
srv, err := New(Options{
SeedDemo: true,
BundleSeedHex: testBundleSeed,
JWTSecret: testJWTSecret,
AuthIssuer: testIssuer,
AuthPortalURL: "https://portal.test.local",
AuthRequired: true,
})
if err != nil {
t.Fatal(err)
}
defer srv.Close()
ts := httptest.NewServer(srv.Handler())
defer ts.Close()
token := signTestJWT(t, jwt.MapClaims{
"iss": testIssuer,
"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.StatusServiceUnavailable {
t.Fatalf("status=%d want 503", 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 // fallback when JWT has no bgp_tenant_id / tenants.bgp
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);