diff --git a/apps/web/.env.example b/apps/web/.env.example new file mode 100644 index 0000000..4c1ea6e --- /dev/null +++ b/apps/web/.env.example @@ -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 diff --git a/apps/web/src/components/layout/app-shell.tsx b/apps/web/src/components/layout/app-shell.tsx index 0726cd3..bf7f839 100644 --- a/apps/web/src/components/layout/app-shell.tsx +++ b/apps/web/src/components/layout/app-shell.tsx @@ -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 + /** 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 = 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 }) { - {NAV_GROUPS.map((group) => ( + {visibleGroups.map((group) => ( {group.label} @@ -178,7 +195,9 @@ export function AppShell({ children }: { children: ReactNode }) { ))} - + + +
diff --git a/apps/web/src/components/layout/apps-menu.tsx b/apps/web/src/components/layout/apps-menu.tsx index fa36544..5ca8fab 100644 --- a/apps/web/src/components/layout/apps-menu.tsx +++ b/apps/web/src/components/layout/apps-menu.tsx @@ -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() { })} - } - className="justify-center text-sm font-medium" - > - Настройки - + {isAuthEnabled() ? ( + } + className="justify-center text-sm font-medium" + > + Настроить на портале + + ) : ( + } + className="justify-center text-sm font-medium" + > + Настройки + + )} diff --git a/apps/web/src/components/layout/nav-user.tsx b/apps/web/src/components/layout/nav-user.tsx new file mode 100644 index 0000000..e16ace6 --- /dev/null +++ b/apps/web/src/components/layout/nav-user.tsx @@ -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 ( + + + + + } + > + + + {fallback} + + +
+ {name} + + {email || '—'} + +
+ +
+ + + + + + {fallback} + + +
+ {name} + + {email || '—'} + +
+
+
+ + + + + } + > + + Настройки UI + + {authOn ? ( + } + > + + Открыть Auth Portal + + ) : null} + + + + + + + + {authOn ? 'Выйти' : 'Сбросить токен'} + + +
+
+
+
+ ) +} diff --git a/apps/web/src/hooks/use-app-switcher.ts b/apps/web/src/hooks/use-app-switcher.ts index 792592c..cf85aa5 100644 --- a/apps/web/src/hooks/use-app-switcher.ts +++ b/apps/web/src/hooks/use-app-switcher.ts @@ -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(() => { + 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 { diff --git a/apps/web/src/lib/api-client.ts b/apps/web/src/lib/api-client.ts index 6d2ad0f..b57ff6d 100644 --- a/apps/web/src/lib/api-client.ts +++ b/apps/web/src/lib/api-client.ts @@ -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) diff --git a/apps/web/src/lib/app-switcher-config.ts b/apps/web/src/lib/app-switcher-config.ts index 8161f2f..4c9fbb2 100644 --- a/apps/web/src/lib/app-switcher-config.ts +++ b/apps/web/src/lib/app-switcher-config.ts @@ -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', diff --git a/apps/web/src/lib/auth.ts b/apps/web/src/lib/auth.ts new file mode 100644 index 0000000..1df0508 --- /dev/null +++ b/apps/web/src/lib/auth.ts @@ -0,0 +1,303 @@ +/** + * Portal JWT SSO integration for EvoBGP UI. + * + * Two independent auth channels: + * - Portal JWT (this module) — SSO from auth-portal, used to gate UI + Bearer + * to EvoBGP API when the API accepts portal-issued tokens. + * - Local API key (see `@/lib/api-client`) — legacy `evobgp_api_token` + * in localStorage; used when auth-portal is disabled or as a fallback. + * + * `VITE_AUTH_ENABLED=false` → keep the API-key gate. + * `VITE_AUTH_ENABLED=true` → require portal JWT; API-key kept only for tools + * (curl/dev) and as backup. + */ + +const TOKEN_KEY = 'evobgp_portal_token' +const HANDOFF_KEY = 'evobgp_portal_401_handoff' +const HANDOFF_AT_KEY = 'evobgp_portal_handoff_at' +/** Min gap between portal handoffs — breaks SSO↔401 redirect storms. */ +const HANDOFF_COOLDOWN_MS = 12_000 + +/** EvoBGP is `bgp` in the auth-portal registry (see APP_IDS). */ +export const CURRENT_APP_ID = 'bgp' + +export type AccessClaims = { + sub: string + email: string + name: string + apps: string[] + permissions: string[] + is_admin?: boolean + iss?: string + exp?: number +} + +export type RuntimeAuthConfig = { + required: boolean + portalUrl: string +} + +let runtimeConfig: RuntimeAuthConfig | null = null +let runtimeConfigPromise: Promise | 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 { + 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 + return { + sub: String(payload.sub ?? ''), + email: String(payload.email ?? ''), + name: String(payload.name ?? ''), + apps: Array.isArray(payload.apps) ? payload.apps.map(String) : [], + permissions: Array.isArray(payload.permissions) + ? payload.permissions.map(String) + : [], + is_admin: Boolean(payload.is_admin), + iss: payload.iss ? String(payload.iss) : undefined, + exp: typeof payload.exp === 'number' ? payload.exp : undefined, + } + } catch { + return null + } +} + +export function getClaims(): AccessClaims | null { + const token = getPortalToken() + if (!token) return null + const claims = decodeClaims(token) + if (!claims) return null + if (claims.exp && claims.exp * 1000 < Date.now()) { + clearPortalToken() + return null + } + return claims +} + +export function hasPermission( + granted: readonly string[], + required: string, +): boolean { + if (granted.includes(required)) return true + const parts = required.split(':') + if (parts.length !== 3) return false + const [app, section, action] = parts + if (action === 'read') { + return ( + granted.includes(`${app}:${section}:write`) || + granted.includes(`${app}:${section}:admin`) + ) + } + if (action === 'write') { + return granted.includes(`${app}:${section}:admin`) + } + return false +} + +/** Access-check: pass when portal auth is disabled or claim grants required. */ +export function can(required: string): boolean { + if (!isAuthEnabled()) return true + const claims = getClaims() + if (!claims) return false + if (!claims.apps.includes(CURRENT_APP_ID)) return false + return hasPermission(claims.permissions, required) +} + +/** Nav path → minimum permission to show the item. Sync with app-shell NAV. */ +export function permissionForPath(pathname: string): string | null { + if (pathname === '/' || pathname.startsWith('/dashboard')) { + return 'bgp:dashboard:read' + } + if (pathname.startsWith('/modules')) return 'bgp:modules:read' + if (pathname.startsWith('/lookup')) return 'bgp:lookup:read' + if (pathname.startsWith('/network')) return 'bgp:network:read' + if (pathname.startsWith('/directories')) return 'bgp:directories:read' + if (pathname.startsWith('/operations')) return 'bgp:operations:read' + if (pathname.startsWith('/firewall')) return 'bgp:firewall:read' + if (pathname.startsWith('/schedule')) return 'bgp:schedule:read' + if (pathname.startsWith('/monitoring')) return 'bgp:monitoring:read' + if (pathname.startsWith('/access')) return 'bgp:access:admin' + if (pathname.startsWith('/tenant-settings')) return 'bgp:tenant_settings:admin' + if (pathname.startsWith('/settings')) return 'bgp:settings:read' + return null +} + +const FALLBACK_PATH = '/dashboard' + +/** First path in the sidebar the current user may open. */ +export function firstAllowedPath(): string { + const candidates: readonly string[] = [ + '/dashboard', + '/modules', + '/lookup', + '/network', + '/directories', + '/operations', + '/firewall', + '/schedule', + '/monitoring', + '/access', + '/tenant-settings', + '/settings', + ] + for (const path of candidates) { + const perm = permissionForPath(path) + if (!perm || can(perm)) return path + } + return FALLBACK_PATH +} diff --git a/apps/web/src/queries/app-switcher.ts b/apps/web/src/queries/app-switcher.ts new file mode 100644 index 0000000..32b326b --- /dev/null +++ b/apps/web/src/queries/app-switcher.ts @@ -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 { + 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, + }) +} diff --git a/apps/web/src/routes/_auth.tsx b/apps/web/src/routes/_auth.tsx index 03229ed..50d4145 100644 --- a/apps/web/src/routes/_auth.tsx +++ b/apps/web/src/routes/_auth.tsx @@ -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, diff --git a/apps/web/src/routes/auth.callback.tsx b/apps/web/src/routes/auth.callback.tsx new file mode 100644 index 0000000..3d0cc7d --- /dev/null +++ b/apps/web/src/routes/auth.callback.tsx @@ -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) => ({ + 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 ( +
+

Сессия не принята

+

+ Повторный вход через auth-portal остановлен (защита от цикла редиректов). + Обычно это несовпадение JWT_SECRET / ISSUER или просроченный токен. + Войдите заново на portal, затем откройте EvoBGP. +

+ + Открыть Auth Portal + +
+ ) + } + return null +} diff --git a/apps/web/src/vite-env.d.ts b/apps/web/src/vite-env.d.ts index 1935306..090af6e 100644 --- a/apps/web/src/vite-env.d.ts +++ b/apps/web/src/vite-env.d.ts @@ -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 { diff --git a/apps/web/tsconfig.tsbuildinfo b/apps/web/tsconfig.tsbuildinfo index 7955207..9791ac3 100644 --- a/apps/web/tsconfig.tsbuildinfo +++ b/apps/web/tsconfig.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/main.tsx","./src/routetree.gen.ts","./src/vite-env.d.ts","./src/components/app-switcher.tsx","./src/components/badge-tabs.tsx","./src/components/category-badge.tsx","./src/components/confirm-dialog.tsx","./src/components/counted-line-tabs.tsx","./src/components/data-grid-cell.tsx","./src/components/data-grid-shell.tsx","./src/components/data-grid-toolbar.tsx","./src/components/drawer-layout.tsx","./src/components/empty-state.tsx","./src/components/form-drawer.tsx","./src/components/kpi-stat-grid.tsx","./src/components/loading-button.tsx","./src/components/mode-toggle.tsx","./src/components/page-header.tsx","./src/components/page-shell.tsx","./src/components/panel-card.tsx","./src/components/query-state.tsx","./src/components/section-cards.tsx","./src/components/select-field.tsx","./src/components/skeletons.tsx","./src/components/status-badge.tsx","./src/components/theme-provider.tsx","./src/components/truncated-text.tsx","./src/components/access/access-api-keys-card.tsx","./src/components/access/access-api-keys-grid.tsx","./src/components/access/api-key-create-dialog.tsx","./src/components/access/api-key-token-dialog.tsx","./src/components/analytics/analytics-activity-list.tsx","./src/components/analytics/analytics-card-shell.tsx","./src/components/analytics/analytics-kpi-row.tsx","./src/components/analytics/analytics-progress.tsx","./src/components/analytics/analytics-segment-control.tsx","./src/components/analytics/chart-bar-strip.tsx","./src/components/analytics/chart-donut-metric.tsx","./src/components/analytics/dashboard-network-capacity-card.tsx","./src/components/analytics/dashboard-operations-flow-card.tsx","./src/components/analytics/dashboard-platform-card.tsx","./src/components/analytics/index.ts","./src/components/analytics/monitoring-health-card.tsx","./src/components/analytics/network-overview-analytics-card.tsx","./src/components/analytics/operations-analytics-card.tsx","./src/components/dashboard/card-dot-field.tsx","./src/components/dashboard/dashboard-activity-timeline.tsx","./src/components/dashboard/dashboard-frame-panel.tsx","./src/components/dashboard/dashboard-kpi-grid.tsx","./src/components/dashboard/dashboard-kpi-sparkline-row.tsx","./src/components/dashboard/dashboard-modules-grid.tsx","./src/components/dashboard/dashboard-network-health.tsx","./src/components/dashboard/dashboard-network-panel.tsx","./src/components/dashboard/dashboard-operations-breakdown.tsx","./src/components/dashboard/dashboard-quick-links.tsx","./src/components/dashboard/dashboard-recent-jobs-grid.tsx","./src/components/dashboard/dashboard-recent-revisions-grid.tsx","./src/components/directories/directories-communities-grid.tsx","./src/components/directories/directories-doh-grid.tsx","./src/components/examples/c-input-group-37.tsx","./src/components/examples/c-select-4.tsx","./src/components/examples/c-tabs-2.tsx","./src/components/examples/c-tabs-6.tsx","./src/components/examples/c-tabs-7.tsx","./src/components/firewall/firewall-clients-grid.tsx","./src/components/firewall/firewall-rule-create-dialog.tsx","./src/components/firewall/firewall-rules-grid.tsx","./src/components/layout/app-shell.tsx","./src/components/layout/apps-menu.tsx","./src/components/layout/command-palette.tsx","./src/components/layout/system-monitor-popover.tsx","./src/components/lookup/lookup-matches-grid.tsx","./src/components/lookup/lookup-search-form.tsx","./src/components/lookup/lookup-summary-kpi.tsx","./src/components/modules/community-select.tsx","./src/components/modules/module-as-entry-dialog.tsx","./src/components/modules/module-cdn-source-dialog.tsx","./src/components/modules/module-domain-entry-dialog.tsx","./src/components/modules/module-entries-grid.tsx","./src/components/modules/module-entries-section.tsx","./src/components/modules/module-ip-range-entry-dialog.tsx","./src/components/modules/module-kpi-cards.tsx","./src/components/modules/modules-list-grid.tsx","./src/components/monitoring/monitoring-ready-grid.tsx","./src/components/network/network-peers-card.tsx","./src/components/network/network-peers-grid.tsx","./src/components/network/network-speakers-card.tsx","./src/components/network/network-speakers-grid.tsx","./src/components/network/peer-form-dialog.tsx","./src/components/network/speaker-form-dialog.tsx","./src/components/operations/operations-jobs-card.tsx","./src/components/operations/operations-jobs-grid.tsx","./src/components/operations/operations-revisions-grid.tsx","./src/components/patterns/donut-breakdown-card.tsx","./src/components/patterns/illustrated-empty-state.tsx","./src/components/patterns/index.ts","./src/components/patterns/kpi-sparkline-card.tsx","./src/components/patterns/metric-tone-styles.ts","./src/components/patterns/panel-corners.tsx","./src/components/patterns/projects-empty-state.tsx","./src/components/patterns/segmented-progress-card.tsx","./src/components/reui/alert.tsx","./src/components/reui/autocomplete.tsx","./src/components/reui/badge.tsx","./src/components/reui/date-selector.tsx","./src/components/reui/filters.tsx","./src/components/reui/frame.tsx","./src/components/reui/icon-stack.tsx","./src/components/reui/number-field.tsx","./src/components/reui/rating.tsx","./src/components/reui/timeline.tsx","./src/components/reui/data-grid/data-grid-column-filter.tsx","./src/components/reui/data-grid/data-grid-column-header.tsx","./src/components/reui/data-grid/data-grid-column-visibility.tsx","./src/components/reui/data-grid/data-grid-pagination.tsx","./src/components/reui/data-grid/data-grid-scroll-area.tsx","./src/components/reui/data-grid/data-grid-table-dnd-rows.tsx","./src/components/reui/data-grid/data-grid-table-dnd.tsx","./src/components/reui/data-grid/data-grid-table-virtual.tsx","./src/components/reui/data-grid/data-grid-table.tsx","./src/components/reui/data-grid/data-grid.tsx","./src/components/reui-kit/detail-panel.tsx","./src/components/reui-kit/filter-utils.ts","./src/components/reui-kit/index.ts","./src/components/reui-kit/kpi-stat-grid.tsx","./src/components/reui-kit/ops-dashboard.tsx","./src/components/reui-kit/quick-action-grid.tsx","./src/components/reui-kit/resource-page.tsx","./src/components/reui-kit/settings-shell.tsx","./src/components/schedule/schedule-agenda-panel.tsx","./src/components/schedule/schedule-calendar-view.tsx","./src/components/schedule/schedule-jobs-card.tsx","./src/components/schedule/schedule-jobs-grid.tsx","./src/components/schedule/schedule-modules-grid.tsx","./src/components/settings/appearance-settings-tab.tsx","./src/components/settings/connection-settings-tab.tsx","./src/components/settings/sections-settings-tab.tsx","./src/components/settings/session-settings-tab.tsx","./src/components/settings/settings-kv-grid.tsx","./src/components/settings/settings-page-shell.tsx","./src/components/settings/settings-setting-field.tsx","./src/components/settings/settings-tabs-data.tsx","./src/components/ui/svgs/anthropicblack.tsx","./src/components/ui/svgs/anthropicwhite.tsx","./src/components/ui/svgs/convex.tsx","./src/components/ui/svgs/discord.tsx","./src/components/ui/svgs/gemini.tsx","./src/components/ui/svgs/googlecloud.tsx","./src/components/ui/svgs/hono.tsx","./src/components/ui/svgs/loom.tsx","./src/components/ui/svgs/mintlify.tsx","./src/components/ui/svgs/n8n.tsx","./src/components/ui/svgs/neon.tsx","./src/components/ui/svgs/openai.tsx","./src/components/ui/svgs/openaidark.tsx","./src/components/ui/svgs/paper.tsx","./src/components/ui/svgs/planetscale.tsx","./src/components/ui/svgs/planetscaledark.tsx","./src/components/ui/svgs/prisma.tsx","./src/components/ui/svgs/prismadark.tsx","./src/components/ui/svgs/remixdark.tsx","./src/components/ui/svgs/remixlight.tsx","./src/components/ui/svgs/resendiconblack.tsx","./src/components/ui/svgs/resendiconwhite.tsx","./src/components/ui/svgs/slack.tsx","./src/components/ui/svgs/stripe.tsx","./src/components/ui/svgs/supabase.tsx","./src/components/ui/svgs/zoom.tsx","./src/hooks/use-app-switcher.ts","./src/hooks/use-client-data-grid.ts","./src/hooks/use-copy-to-clipboard.ts","./src/hooks/use-file-upload.ts","./src/hooks/use-mobile.ts","./src/lib/api-client.ts","./src/lib/app-switcher-config.ts","./src/lib/data-grid-defaults.ts","./src/lib/queryclient.ts","./src/lib/router.ts","./src/lib/ui-labels.ts","./src/lib/ui-surface.ts","./src/lib/access/api-key-labels.ts","./src/lib/metrics/deployment-progress.ts","./src/lib/metrics/index.ts","./src/lib/metrics/job-status-breakdown.ts","./src/lib/metrics/module-type-breakdown.ts","./src/lib/metrics/peer-capacity-bars.ts","./src/lib/metrics/peer-session-breakdown.ts","./src/lib/metrics/readiness-breakdown.ts","./src/lib/metrics/recent-platform-activity.ts","./src/lib/metrics/types.ts","./src/lib/modules/display.ts","./src/lib/modules/helpers.ts","./src/queries/api-keys.ts","./src/queries/auth.ts","./src/queries/directories.ts","./src/queries/firewall.ts","./src/queries/lookup.ts","./src/queries/modules.ts","./src/queries/monitoring.ts","./src/queries/network.ts","./src/queries/operations.ts","./src/queries/overview.ts","./src/queries/settings.ts","./src/routes/__root.tsx","./src/routes/_auth.tsx","./src/routes/index.tsx","./src/routes/_auth/access.tsx","./src/routes/_auth/dashboard.tsx","./src/routes/_auth/directories.tsx","./src/routes/_auth/firewall.tsx","./src/routes/_auth/lookup.tsx","./src/routes/_auth/monitoring.tsx","./src/routes/_auth/network.tsx","./src/routes/_auth/operations.tsx","./src/routes/_auth/schedule.tsx","./src/routes/_auth/settings.tsx","./src/routes/_auth/tenant-settings.tsx","./src/routes/_auth/modules/$moduleid.tsx","./src/routes/_auth/modules/index.tsx","./src/routes/_auth/modules/new.tsx","./src/types/api.ts","./vite.config.ts"],"version":"5.9.3"} \ No newline at end of file +{"root":["./src/main.tsx","./src/routetree.gen.ts","./src/vite-env.d.ts","./src/components/app-switcher.tsx","./src/components/badge-tabs.tsx","./src/components/category-badge.tsx","./src/components/confirm-dialog.tsx","./src/components/counted-line-tabs.tsx","./src/components/data-grid-cell.tsx","./src/components/data-grid-shell.tsx","./src/components/data-grid-toolbar.tsx","./src/components/drawer-layout.tsx","./src/components/empty-state.tsx","./src/components/form-drawer.tsx","./src/components/kpi-stat-grid.tsx","./src/components/loading-button.tsx","./src/components/mode-toggle.tsx","./src/components/page-header.tsx","./src/components/page-shell.tsx","./src/components/panel-card.tsx","./src/components/query-state.tsx","./src/components/section-cards.tsx","./src/components/select-field.tsx","./src/components/skeletons.tsx","./src/components/status-badge.tsx","./src/components/theme-provider.tsx","./src/components/truncated-text.tsx","./src/components/access/access-api-keys-card.tsx","./src/components/access/access-api-keys-grid.tsx","./src/components/access/api-key-create-dialog.tsx","./src/components/access/api-key-token-dialog.tsx","./src/components/analytics/analytics-activity-list.tsx","./src/components/analytics/analytics-card-shell.tsx","./src/components/analytics/analytics-kpi-row.tsx","./src/components/analytics/analytics-progress.tsx","./src/components/analytics/analytics-segment-control.tsx","./src/components/analytics/chart-bar-strip.tsx","./src/components/analytics/chart-donut-metric.tsx","./src/components/analytics/dashboard-network-capacity-card.tsx","./src/components/analytics/dashboard-operations-flow-card.tsx","./src/components/analytics/dashboard-platform-card.tsx","./src/components/analytics/index.ts","./src/components/analytics/monitoring-health-card.tsx","./src/components/analytics/network-overview-analytics-card.tsx","./src/components/analytics/operations-analytics-card.tsx","./src/components/dashboard/card-dot-field.tsx","./src/components/dashboard/dashboard-activity-timeline.tsx","./src/components/dashboard/dashboard-frame-panel.tsx","./src/components/dashboard/dashboard-kpi-grid.tsx","./src/components/dashboard/dashboard-kpi-sparkline-row.tsx","./src/components/dashboard/dashboard-modules-grid.tsx","./src/components/dashboard/dashboard-network-health.tsx","./src/components/dashboard/dashboard-network-panel.tsx","./src/components/dashboard/dashboard-operations-breakdown.tsx","./src/components/dashboard/dashboard-quick-links.tsx","./src/components/dashboard/dashboard-recent-jobs-grid.tsx","./src/components/dashboard/dashboard-recent-revisions-grid.tsx","./src/components/directories/directories-communities-grid.tsx","./src/components/directories/directories-doh-grid.tsx","./src/components/examples/c-input-group-37.tsx","./src/components/examples/c-select-4.tsx","./src/components/examples/c-tabs-2.tsx","./src/components/examples/c-tabs-6.tsx","./src/components/examples/c-tabs-7.tsx","./src/components/firewall/firewall-clients-grid.tsx","./src/components/firewall/firewall-rule-create-dialog.tsx","./src/components/firewall/firewall-rules-grid.tsx","./src/components/layout/app-shell.tsx","./src/components/layout/apps-menu.tsx","./src/components/layout/command-palette.tsx","./src/components/layout/nav-user.tsx","./src/components/layout/system-monitor-popover.tsx","./src/components/lookup/lookup-matches-grid.tsx","./src/components/lookup/lookup-search-form.tsx","./src/components/lookup/lookup-summary-kpi.tsx","./src/components/modules/community-select.tsx","./src/components/modules/module-as-entry-dialog.tsx","./src/components/modules/module-cdn-source-dialog.tsx","./src/components/modules/module-domain-entry-dialog.tsx","./src/components/modules/module-entries-grid.tsx","./src/components/modules/module-entries-section.tsx","./src/components/modules/module-ip-range-entry-dialog.tsx","./src/components/modules/module-kpi-cards.tsx","./src/components/modules/modules-list-grid.tsx","./src/components/monitoring/monitoring-ready-grid.tsx","./src/components/network/network-kpi.tsx","./src/components/network/network-peers-card.tsx","./src/components/network/network-peers-grid.tsx","./src/components/network/network-speakers-card.tsx","./src/components/network/network-speakers-grid.tsx","./src/components/network/peer-form-dialog.tsx","./src/components/network/speaker-form-dialog.tsx","./src/components/operations/operations-jobs-card.tsx","./src/components/operations/operations-jobs-grid.tsx","./src/components/operations/operations-revisions-grid.tsx","./src/components/patterns/donut-breakdown-card.tsx","./src/components/patterns/illustrated-empty-state.tsx","./src/components/patterns/index.ts","./src/components/patterns/kpi-sparkline-card.tsx","./src/components/patterns/metric-tone-styles.ts","./src/components/patterns/panel-corners.tsx","./src/components/patterns/projects-empty-state.tsx","./src/components/patterns/segmented-progress-card.tsx","./src/components/reui/alert.tsx","./src/components/reui/autocomplete.tsx","./src/components/reui/badge.tsx","./src/components/reui/date-selector.tsx","./src/components/reui/filters.tsx","./src/components/reui/frame.tsx","./src/components/reui/icon-stack.tsx","./src/components/reui/number-field.tsx","./src/components/reui/rating.tsx","./src/components/reui/timeline.tsx","./src/components/reui/data-grid/data-grid-column-filter.tsx","./src/components/reui/data-grid/data-grid-column-header.tsx","./src/components/reui/data-grid/data-grid-column-visibility.tsx","./src/components/reui/data-grid/data-grid-pagination.tsx","./src/components/reui/data-grid/data-grid-scroll-area.tsx","./src/components/reui/data-grid/data-grid-table-dnd-rows.tsx","./src/components/reui/data-grid/data-grid-table-dnd.tsx","./src/components/reui/data-grid/data-grid-table-virtual.tsx","./src/components/reui/data-grid/data-grid-table.tsx","./src/components/reui/data-grid/data-grid.tsx","./src/components/reui-kit/detail-panel.tsx","./src/components/reui-kit/filter-utils.ts","./src/components/reui-kit/index.ts","./src/components/reui-kit/kpi-stat-grid.tsx","./src/components/reui-kit/ops-dashboard.tsx","./src/components/reui-kit/quick-action-grid.tsx","./src/components/reui-kit/resource-page.tsx","./src/components/reui-kit/settings-shell.tsx","./src/components/schedule/schedule-agenda-panel.tsx","./src/components/schedule/schedule-calendar-view.tsx","./src/components/schedule/schedule-jobs-card.tsx","./src/components/schedule/schedule-jobs-grid.tsx","./src/components/schedule/schedule-modules-grid.tsx","./src/components/settings/appearance-settings-tab.tsx","./src/components/settings/connection-settings-tab.tsx","./src/components/settings/sections-settings-tab.tsx","./src/components/settings/session-settings-tab.tsx","./src/components/settings/settings-kv-grid.tsx","./src/components/settings/settings-page-shell.tsx","./src/components/settings/settings-setting-field.tsx","./src/components/settings/settings-tabs-data.tsx","./src/components/ui/svgs/anthropicblack.tsx","./src/components/ui/svgs/anthropicwhite.tsx","./src/components/ui/svgs/convex.tsx","./src/components/ui/svgs/discord.tsx","./src/components/ui/svgs/gemini.tsx","./src/components/ui/svgs/googlecloud.tsx","./src/components/ui/svgs/hono.tsx","./src/components/ui/svgs/loom.tsx","./src/components/ui/svgs/mintlify.tsx","./src/components/ui/svgs/n8n.tsx","./src/components/ui/svgs/neon.tsx","./src/components/ui/svgs/openai.tsx","./src/components/ui/svgs/openaidark.tsx","./src/components/ui/svgs/paper.tsx","./src/components/ui/svgs/planetscale.tsx","./src/components/ui/svgs/planetscaledark.tsx","./src/components/ui/svgs/prisma.tsx","./src/components/ui/svgs/prismadark.tsx","./src/components/ui/svgs/remixdark.tsx","./src/components/ui/svgs/remixlight.tsx","./src/components/ui/svgs/resendiconblack.tsx","./src/components/ui/svgs/resendiconwhite.tsx","./src/components/ui/svgs/slack.tsx","./src/components/ui/svgs/stripe.tsx","./src/components/ui/svgs/supabase.tsx","./src/components/ui/svgs/zoom.tsx","./src/hooks/use-app-switcher.ts","./src/hooks/use-client-data-grid.ts","./src/hooks/use-copy-to-clipboard.ts","./src/hooks/use-file-upload.ts","./src/hooks/use-mobile.ts","./src/lib/api-client.ts","./src/lib/app-switcher-config.ts","./src/lib/auth.ts","./src/lib/data-grid-defaults.ts","./src/lib/queryclient.ts","./src/lib/router.ts","./src/lib/ui-labels.ts","./src/lib/ui-surface.ts","./src/lib/access/api-key-labels.ts","./src/lib/metrics/deployment-progress.ts","./src/lib/metrics/index.ts","./src/lib/metrics/job-status-breakdown.ts","./src/lib/metrics/module-type-breakdown.ts","./src/lib/metrics/peer-capacity-bars.ts","./src/lib/metrics/peer-session-breakdown.ts","./src/lib/metrics/readiness-breakdown.ts","./src/lib/metrics/recent-platform-activity.ts","./src/lib/metrics/types.ts","./src/lib/modules/display.ts","./src/lib/modules/helpers.ts","./src/queries/api-keys.ts","./src/queries/app-switcher.ts","./src/queries/auth.ts","./src/queries/directories.ts","./src/queries/firewall.ts","./src/queries/lookup.ts","./src/queries/modules.ts","./src/queries/monitoring.ts","./src/queries/network.ts","./src/queries/operations.ts","./src/queries/overview.ts","./src/queries/settings.ts","./src/routes/__root.tsx","./src/routes/_auth.tsx","./src/routes/auth.callback.tsx","./src/routes/index.tsx","./src/routes/_auth/access.tsx","./src/routes/_auth/dashboard.tsx","./src/routes/_auth/directories.tsx","./src/routes/_auth/firewall.tsx","./src/routes/_auth/lookup.tsx","./src/routes/_auth/monitoring.tsx","./src/routes/_auth/network.tsx","./src/routes/_auth/operations.tsx","./src/routes/_auth/schedule.tsx","./src/routes/_auth/settings.tsx","./src/routes/_auth/tenant-settings.tsx","./src/routes/_auth/modules/$moduleid.tsx","./src/routes/_auth/modules/index.tsx","./src/routes/_auth/modules/new.tsx","./src/types/api.ts","./vite.config.ts"],"version":"5.9.3"} \ No newline at end of file diff --git a/cmd/evobgp-all/main.go b/cmd/evobgp-all/main.go index 457afdf..3e71dc0 100644 --- a/cmd/evobgp-all/main.go +++ b/cmd/evobgp-all/main.go @@ -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 == "" { diff --git a/cmd/evobgp-api/main.go b/cmd/evobgp-api/main.go index 357df9f..d292931 100644 --- a/cmd/evobgp-api/main.go +++ b/cmd/evobgp-api/main.go @@ -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 == "" { diff --git a/docs/access.md b/docs/access.md index 029ffb1..17d94cb 100644 --- a/docs/access.md +++ b/docs/access.md @@ -2,6 +2,24 @@ Как выдавать доступ к control plane API, веб-клиентам и репликам BIRD (`evobgp-node`). Секреты храните в менеджере секретов, переменных окружения оркестратора или зашифрованных файлах — не коммитьте реальные ключи в Git. +## Portal SSO (JWT) + +Единый вход через **auth-portal** (app id `bgp`). См. [integrate-evobgp.md](https://git.shts.su/denozord/auth-portal/src/branch/main/docs/integrate-evobgp.md) в репозитории auth-portal. + +| Переменная | Назначение | +|------------|------------| +| `AUTH_REQUIRED` / `EVOBGP_AUTH_REQUIRED` | Включить проверку portal JWT для UI | +| `AUTH_JWT_SECRET` / `EVOBGP_AUTH_JWT_SECRET` | Тот же секрет, что `JWT_SECRET` портала (HS256) | +| `AUTH_ISSUER` | Issuer JWT (как на портале) | +| `AUTH_PORTAL_URL` | URL портала (также `GET /v1/auth/config`) | +| `EVOBGP_PORTAL_TENANT_ID` | Tenant для всех portal JWT (обязателен при JWT) | + +Права — строки `bgp:
:` из каталога портала (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`) Формат переменной окружения: список записей через **запятую** без пробелов внутри логики парсера (пробелы вокруг записей допускаются при обрезке). Каждая запись: diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 308733d..b6643e4 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -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 ` из `EVOBGP_API_KEYS` / таблицы `api_key` (роли `viewer`/`editor`/`operator`/`node`/`firewall`). + - **Portal JWT** — HS256 от auth-portal; claim `apps` должен содержать `bgp`; права `bgp:
:`; tenant из `EVOBGP_PORTAL_TENANT_ID`. + Публично: `GET /v1/auth/config` → `{ required, portal_url }`. - Заголовок `X-Tenant-Id` допускается только для супер-ролей (явный tenant); иначе tenant берётся из API-ключа. + **Роли API key** (матрица): `viewer`, `editor`, `operator`, `node`. Нода использует отдельные пути и ключ с ролью `node`. + JWT permissions мапятся на ту же лестницу (`:read`→viewer, `:write`→editor, `:admin`→operator). + + Заголовок `X-Tenant-Id` допускается только для супер-ролей (явный tenant); иначе tenant берётся из API-ключа / portal tenant env. license: name: Proprietary identifier: LicenseRef-Proprietary diff --git a/docs/ui-design-contract.md b/docs/ui-design-contract.md index e6fd9ba..e35b447 100644 --- a/docs/ui-design-contract.md +++ b/docs/ui-design-contract.md @@ -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). diff --git a/go.mod b/go.mod index f379872..a85c6b6 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index 2a7a2ec..039fce1 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/httpapi/auth.go b/internal/httpapi/auth.go index 2677a54..00f7acb 100644 --- a/internal/httpapi/auth.go +++ b/internal/httpapi/auth.go @@ -6,18 +6,32 @@ import ( "strings" "evobgp/internal/authkey" + + "github.com/golang-jwt/jwt/v5" ) type ctxKey int const authCtxKey ctxKey = 1 +// Auth kinds distinguish API key sessions from portal JWT sessions. +const ( + AuthKindAPIKey = "apikey" + AuthKindJWT = "jwt" +) + // Auth holds resolved API identity for a request. type Auth struct { TenantID string - Role string // viewer, editor, operator, node + Role string // viewer, editor, operator, node, firewall (apikeys only) Token string APIKeyID string // non-empty for DB-managed keys + // Portal / dual-auth fields (empty for API keys unless noted). + Kind string // "apikey" | "jwt" + UserID string // JWT sub + Email string // JWT email claim + Permissions []string // JWT permissions claim (bgp:*) + IsAdmin bool // JWT is_admin claim } func authFromContext(ctx context.Context) (Auth, bool) { @@ -56,6 +70,23 @@ func parseAPIKeysSpec(spec string) []apiKeyRecord { return out } +// looksLikeJWT reports whether raw is a compact JWS (three dot-separated segments, non-empty). +func looksLikeJWT(raw string) bool { + if raw == "" { + return false + } + parts := strings.Split(raw, ".") + if len(parts) != 3 { + return false + } + for _, p := range parts { + if strings.TrimSpace(p) == "" { + return false + } + } + return true +} + func (s *Server) authMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { h := r.Header.Get("Authorization") @@ -65,6 +96,16 @@ func (s *Server) authMiddleware(next http.Handler) http.Handler { return } raw := strings.TrimSpace(strings.TrimPrefix(h, p)) + if looksLikeJWT(raw) && strings.TrimSpace(s.jwtSecret) != "" { + a, status, msg, ok := s.resolveJWT(raw) + if !ok { + writeProblem(w, status, http.StatusText(status), msg) + return + } + r = r.WithContext(context.WithValue(r.Context(), authCtxKey, a)) + next.ServeHTTP(w, r) + return + } a, ok := s.resolveAuth(raw) if !ok { writeProblem(w, http.StatusUnauthorized, "Unauthorized", "unknown api key") @@ -79,10 +120,16 @@ func (s *Server) authMiddleware(next http.Handler) http.Handler { } func authFromKeyRecord(raw string, rec apiKeyRecord) Auth { - return Auth{TenantID: rec.tenantID, Role: rec.role, Token: raw, APIKeyID: rec.keyID} + return Auth{ + Kind: AuthKindAPIKey, + TenantID: rec.tenantID, + Role: rec.role, + Token: raw, + APIKeyID: rec.keyID, + } } -// resolveAuth maps a bearer token to tenant identity. +// resolveAuth maps a bearer token to tenant identity (API key path). // For the literal token "dev", the demo shortcut (devAuth) takes precedence when demo-seed // is available; env/DB mapping is used only when demo tenant is absent. func (s *Server) resolveAuth(raw string) (Auth, bool) { @@ -99,23 +146,96 @@ func (s *Server) resolveAuth(raw string) (Auth, bool) { if !ok { if s.firewallResolver != nil { if fw, ok := s.firewallResolver.Lookup(raw); ok { - return Auth{TenantID: fw.tenantID, Role: "firewall", Token: raw, APIKeyID: fw.clientID}, true + return Auth{Kind: AuthKindAPIKey, TenantID: fw.tenantID, Role: "firewall", Token: raw, APIKeyID: fw.clientID}, true } } if client, err := s.store.LookupFirewallClientByTokenHash(authkey.HashToken(raw)); err == nil { - return Auth{TenantID: client.TenantID, Role: "firewall", Token: raw, APIKeyID: client.ID}, true + return Auth{Kind: AuthKindAPIKey, TenantID: client.TenantID, Role: "firewall", Token: raw, APIKeyID: client.ID}, true } return Auth{}, false } return authFromKeyRecord(raw, rec), true } +// resolveJWT parses and validates a portal HS256 token, returning an Auth on success. +// Returns (auth, status, detail, ok). status/detail are used when ok=false. +func (s *Server) resolveJWT(raw string) (Auth, int, string, bool) { + if strings.TrimSpace(s.portalTenantID) == "" { + return Auth{}, http.StatusServiceUnavailable, "portal tenant not configured (EVOBGP_PORTAL_TENANT_ID)", false + } + tok, err := jwt.Parse(raw, func(t *jwt.Token) (any, error) { + if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, jwt.ErrSignatureInvalid + } + return []byte(s.jwtSecret), nil + }, jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Alg()})) + if err != nil || tok == nil || !tok.Valid { + return Auth{}, http.StatusUnauthorized, "invalid jwt", false + } + claims, ok := tok.Claims.(jwt.MapClaims) + if !ok { + return Auth{}, http.StatusUnauthorized, "invalid jwt claims", false + } + if iss := strings.TrimSpace(s.authIssuer); iss != "" { + got, _ := claims["iss"].(string) + if strings.TrimSpace(got) != iss { + return Auth{}, http.StatusUnauthorized, "jwt issuer mismatch", false + } + } + apps := coerceStringSlice(claims["apps"]) + if !containsFold(apps, "bgp") { + return Auth{}, http.StatusForbidden, "jwt does not grant access to bgp app", false + } + sub, _ := claims["sub"].(string) + if strings.TrimSpace(sub) == "" { + return Auth{}, http.StatusUnauthorized, "jwt missing sub", false + } + email, _ := claims["email"].(string) + perms := coerceStringSlice(claims["permissions"]) + isAdmin, _ := claims["is_admin"].(bool) + return Auth{ + Kind: AuthKindJWT, + TenantID: s.portalTenantID, + UserID: strings.TrimSpace(sub), + Email: strings.TrimSpace(email), + Permissions: perms, + IsAdmin: isAdmin, + Token: raw, + }, 0, "", true +} + +func coerceStringSlice(v any) []string { + switch t := v.(type) { + case []string: + return t + case []any: + out := make([]string, 0, len(t)) + for _, x := range t { + if s, ok := x.(string); ok { + out = append(out, s) + } + } + return out + default: + return nil + } +} + +func containsFold(items []string, needle string) bool { + for _, x := range items { + if strings.EqualFold(strings.TrimSpace(x), needle) { + return true + } + } + return false +} + func (s *Server) devAuth() (Auth, bool) { tid, _, _, _, _ := s.store.DemoIDs() if tid == "" { return Auth{}, false } - return Auth{TenantID: tid, Role: "operator", Token: "dev"}, true + return Auth{Kind: AuthKindAPIKey, TenantID: tid, Role: "operator", Token: "dev"}, true } func roleLevel(role string) int { @@ -131,7 +251,8 @@ func roleLevel(role string) int { } } -// requireAtLeast rejects node role and enforces viewer/editor/operator ladder. +// requireAtLeast rejects node/firewall roles and enforces viewer/editor/operator ladder for API keys. +// New code should call requirePerm which supports JWT permissions in addition to API-key roles. func (s *Server) requireAtLeast(w http.ResponseWriter, a Auth, need string) bool { if strings.ToLower(a.Role) == "node" { writeProblem(w, http.StatusForbidden, "Forbidden", "node role cannot access this resource") diff --git a/internal/httpapi/auth_jwt_test.go b/internal/httpapi/auth_jwt_test.go new file mode 100644 index 0000000..5df51eb --- /dev/null +++ b/internal/httpapi/auth_jwt_test.go @@ -0,0 +1,225 @@ +package httpapi + +import ( + "io" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +const ( + testJWTSecret = "test-secret-32-bytes-long-abcdef" + testIssuer = "https://auth.test.local" +) + +func signTestJWT(t *testing.T, claims jwt.MapClaims) string { + t.Helper() + tok := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + s, err := tok.SignedString([]byte(testJWTSecret)) + if err != nil { + t.Fatalf("sign jwt: %v", err) + } + return s +} + +func newJWTTestServer(t *testing.T) (*Server, string) { + t.Helper() + srv, err := New(Options{ + SeedDemo: true, + BundleSeedHex: testBundleSeed, + JWTSecret: testJWTSecret, + AuthIssuer: testIssuer, + AuthPortalURL: "https://portal.test.local", + AuthRequired: true, + PortalTenantID: "", // filled after DemoIDs + }) + if err != nil { + t.Fatal(err) + } + tenant, _, _, _, _ := srv.Store().DemoIDs() + // Override tenant to match seed. + srv.portalTenantID = tenant + return srv, tenant +} + +func TestAuthJWTAcceptedWithBGPApp(t *testing.T) { + srv, _ := newJWTTestServer(t) + defer srv.Close() + + ts := httptest.NewServer(srv.Handler()) + defer ts.Close() + + token := signTestJWT(t, jwt.MapClaims{ + "iss": testIssuer, + "sub": "user-1", + "email": "user@test.local", + "apps": []string{"bgp"}, + "permissions": []string{"bgp:modules:read"}, + "exp": time.Now().Add(time.Hour).Unix(), + }) + + req, _ := http.NewRequest(http.MethodGet, ts.URL+"/v1/modules", nil) + req.Header.Set("Authorization", "Bearer "+token) + resp, err := ts.Client().Do(req) + if err != nil { + t.Fatal(err) + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusOK { + b, _ := io.ReadAll(resp.Body) + t.Fatalf("status=%d body=%s", resp.StatusCode, b) + } +} + +func TestAuthJWTRejectedOnWrongIssuer(t *testing.T) { + srv, _ := newJWTTestServer(t) + defer srv.Close() + + ts := httptest.NewServer(srv.Handler()) + defer ts.Close() + + token := signTestJWT(t, jwt.MapClaims{ + "iss": "https://other.example.com", + "sub": "user-1", + "apps": []string{"bgp"}, + "exp": time.Now().Add(time.Hour).Unix(), + }) + + req, _ := http.NewRequest(http.MethodGet, ts.URL+"/v1/modules", nil) + req.Header.Set("Authorization", "Bearer "+token) + resp, err := ts.Client().Do(req) + if err != nil { + t.Fatal(err) + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusUnauthorized { + t.Fatalf("status=%d want 401", resp.StatusCode) + } +} + +func TestAuthJWTRejectedWhenBGPAppMissing(t *testing.T) { + srv, _ := newJWTTestServer(t) + defer srv.Close() + + ts := httptest.NewServer(srv.Handler()) + defer ts.Close() + + token := signTestJWT(t, jwt.MapClaims{ + "iss": testIssuer, + "sub": "user-1", + "apps": []string{"cfdm", "portal"}, + "exp": time.Now().Add(time.Hour).Unix(), + }) + + req, _ := http.NewRequest(http.MethodGet, ts.URL+"/v1/modules", nil) + req.Header.Set("Authorization", "Bearer "+token) + resp, err := ts.Client().Do(req) + if err != nil { + t.Fatal(err) + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusForbidden { + t.Fatalf("status=%d want 403", resp.StatusCode) + } +} + +func TestAuthJWTIsAdminBypassesPermissions(t *testing.T) { + srv, _ := newJWTTestServer(t) + defer srv.Close() + + ts := httptest.NewServer(srv.Handler()) + defer ts.Close() + + token := signTestJWT(t, jwt.MapClaims{ + "iss": testIssuer, + "sub": "admin-1", + "apps": []string{"bgp"}, + "is_admin": true, + "exp": time.Now().Add(time.Hour).Unix(), + }) + + req, _ := http.NewRequest(http.MethodGet, ts.URL+"/v1/api-keys", nil) + req.Header.Set("Authorization", "Bearer "+token) + resp, err := ts.Client().Do(req) + if err != nil { + t.Fatal(err) + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusOK { + b, _ := io.ReadAll(resp.Body) + t.Fatalf("status=%d body=%s", resp.StatusCode, b) + } +} + +func TestAuthJWTMissingPermissionRejected(t *testing.T) { + srv, _ := newJWTTestServer(t) + defer srv.Close() + + ts := httptest.NewServer(srv.Handler()) + defer ts.Close() + + token := signTestJWT(t, jwt.MapClaims{ + "iss": testIssuer, + "sub": "user-1", + "apps": []string{"bgp"}, + "permissions": []string{"bgp:modules:read"}, + "exp": time.Now().Add(time.Hour).Unix(), + }) + + req, _ := http.NewRequest(http.MethodGet, ts.URL+"/v1/api-keys", nil) + req.Header.Set("Authorization", "Bearer "+token) + resp, err := ts.Client().Do(req) + if err != nil { + t.Fatal(err) + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusForbidden { + t.Fatalf("status=%d want 403", resp.StatusCode) + } +} + +func TestAuthConfigPublic(t *testing.T) { + srv, _ := newJWTTestServer(t) + defer srv.Close() + + ts := httptest.NewServer(srv.Handler()) + defer ts.Close() + + req, _ := http.NewRequest(http.MethodGet, ts.URL+"/v1/auth/config", nil) + resp, err := ts.Client().Do(req) + if err != nil { + t.Fatal(err) + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusOK { + b, _ := io.ReadAll(resp.Body) + t.Fatalf("status=%d body=%s", resp.StatusCode, b) + } +} + +func TestHasPermissionSupersets(t *testing.T) { + cases := []struct { + name string + granted []string + want string + ok bool + }{ + {"exact-read", []string{"bgp:modules:read"}, "bgp:modules:read", true}, + {"write-covers-read", []string{"bgp:modules:write"}, "bgp:modules:read", true}, + {"admin-covers-write", []string{"bgp:modules:admin"}, "bgp:modules:write", true}, + {"read-does-not-cover-write", []string{"bgp:modules:read"}, "bgp:modules:write", false}, + {"different-section", []string{"bgp:network:admin"}, "bgp:modules:read", false}, + {"empty-granted", nil, "bgp:modules:read", false}, + {"malformed-required", []string{"bgp:modules:admin"}, "bgp:modules", false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := HasPermission(tc.granted, tc.want); got != tc.ok { + t.Fatalf("HasPermission(%v, %q) = %v, want %v", tc.granted, tc.want, got, tc.ok) + } + }) + } +} diff --git a/internal/httpapi/permissions.go b/internal/httpapi/permissions.go new file mode 100644 index 0000000..4c711c9 --- /dev/null +++ b/internal/httpapi/permissions.go @@ -0,0 +1,107 @@ +package httpapi + +import ( + "net/http" + "strings" +) + +// Portal permission strings. Format: :
: (bgp:modules:read). +// Superset order: admin ⊃ write ⊃ read for the same :
. +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 +} diff --git a/internal/httpapi/routes.go b/internal/httpapi/routes.go index 34b9c1b..4baa9e1 100644 --- a/internal/httpapi/routes.go +++ b/internal/httpapi/routes.go @@ -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")) diff --git a/internal/httpapi/routes_api_keys.go b/internal/httpapi/routes_api_keys.go index 1743778..170d976 100644 --- a/internal/httpapi/routes_api_keys.go +++ b/internal/httpapi/routes_api_keys.go @@ -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")) diff --git a/internal/httpapi/routes_crud.go b/internal/httpapi/routes_crud.go index ff38314..4026033 100644 --- a/internal/httpapi/routes_crud.go +++ b/internal/httpapi/routes_crud.go @@ -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 diff --git a/internal/httpapi/routes_firewall.go b/internal/httpapi/routes_firewall.go index 2924ed3..2409692 100644 --- a/internal/httpapi/routes_firewall.go +++ b/internal/httpapi/routes_firewall.go @@ -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")) diff --git a/internal/httpapi/routes_lookup.go b/internal/httpapi/routes_lookup.go index d903596..7d7acb8 100644 --- a/internal/httpapi/routes_lookup.go +++ b/internal/httpapi/routes_lookup.go @@ -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")) diff --git a/internal/httpapi/routes_maintenance.go b/internal/httpapi/routes_maintenance.go index 2796ac9..0334a3d 100644 --- a/internal/httpapi/routes_maintenance.go +++ b/internal/httpapi/routes_maintenance.go @@ -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") diff --git a/internal/httpapi/routes_postgres_maintenance.go b/internal/httpapi/routes_postgres_maintenance.go index 3415a37..f0d9eea 100644 --- a/internal/httpapi/routes_postgres_maintenance.go +++ b/internal/httpapi/routes_postgres_maintenance.go @@ -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") diff --git a/internal/httpapi/routes_postgres_monitoring.go b/internal/httpapi/routes_postgres_monitoring.go index 522e8d3..c37ab9f 100644 --- a/internal/httpapi/routes_postgres_monitoring.go +++ b/internal/httpapi/routes_postgres_monitoring.go @@ -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 diff --git a/internal/httpapi/routes_revisions_prune.go b/internal/httpapi/routes_revisions_prune.go index f8a3391..e767bac 100644 --- a/internal/httpapi/routes_revisions_prune.go +++ b/internal/httpapi/routes_revisions_prune.go @@ -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) diff --git a/internal/httpapi/routes_runtime_logs.go b/internal/httpapi/routes_runtime_logs.go index 7aeccc8..b701202 100644 --- a/internal/httpapi/routes_runtime_logs.go +++ b/internal/httpapi/routes_runtime_logs.go @@ -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") diff --git a/internal/httpapi/server.go b/internal/httpapi/server.go index 3affbeb..6e7ab4d 100644 --- a/internal/httpapi/server.go +++ b/internal/httpapi/server.go @@ -36,6 +36,13 @@ type Server struct { runtimeLogs *runtimelogs.Service runtimeLogsPolicyTenant string mux *http.ServeMux + + // Portal / dual-auth (JWT) configuration. + jwtSecret string + authIssuer string + authPortalURL string + portalTenantID string + authRequired bool } // Options configures the API server. @@ -49,6 +56,13 @@ type Options struct { CORSAllowedOrigins string // RuntimeLogsPolicyTenant overrides tenant for auto-cleanup scheduler settings (optional). RuntimeLogsPolicyTenant string + + // Portal / dual-auth (JWT) — leave empty to disable JWT path. + JWTSecret string // AUTH_JWT_SECRET / EVOBGP_AUTH_JWT_SECRET (HS256 shared secret) + AuthIssuer string // AUTH_ISSUER (expected iss claim; default https://auth.shnt.top) + AuthPortalURL string // AUTH_PORTAL_URL (returned by /v1/auth/config for the UI) + PortalTenantID string // EVOBGP_PORTAL_TENANT_ID (single tenant scope for JWT users) + AuthRequired bool // AUTH_REQUIRED / EVOBGP_AUTH_REQUIRED (surfaced via /v1/auth/config) } // New constructs Server and wiring for async jobs. @@ -104,6 +118,14 @@ func New(opts Options) (*Server, error) { cdnHTTP: NewCDNHTTPClient(), runtimeLogs: runtimelogs.NewService(runtimelogs.ConfigFromEnv()), runtimeLogsPolicyTenant: strings.TrimSpace(opts.RuntimeLogsPolicyTenant), + jwtSecret: strings.TrimSpace(opts.JWTSecret), + authIssuer: strings.TrimSpace(opts.AuthIssuer), + authPortalURL: strings.TrimSpace(opts.AuthPortalURL), + portalTenantID: strings.TrimSpace(opts.PortalTenantID), + authRequired: opts.AuthRequired, + } + if s.authIssuer == "" { + s.authIssuer = "https://auth.shnt.top" } s.mux = http.NewServeMux() s.registerRoutes() diff --git a/internal/httpapi/speakers.go b/internal/httpapi/speakers.go index ea21742..0ec11ae 100644 --- a/internal/httpapi/speakers.go +++ b/internal/httpapi/speakers.go @@ -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{ diff --git a/internal/repository/postgres.go b/internal/repository/postgres.go index 3ec21ed..a468c34 100644 --- a/internal/repository/postgres.go +++ b/internal/repository/postgres.go @@ -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 } diff --git a/internal/repository/postgres_firewall.go b/internal/repository/postgres_firewall.go index 86319e8..6aaef6f 100644 --- a/internal/repository/postgres_firewall.go +++ b/internal/repository/postgres_firewall.go @@ -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 } diff --git a/internal/store/backend.go b/internal/store/backend.go index 4f91205..18b2dab 100644 --- a/internal/store/backend.go +++ b/internal/store/backend.go @@ -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 diff --git a/internal/store/firewall_types.go b/internal/store/firewall_types.go index 88f4294..a2c01a3 100644 --- a/internal/store/firewall_types.go +++ b/internal/store/firewall_types.go @@ -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. diff --git a/internal/store/memory.go b/internal/store/memory.go index f2dd2a0..04c7db8 100644 --- a/internal/store/memory.go +++ b/internal/store/memory.go @@ -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 { diff --git a/internal/store/memory_crud.go b/internal/store/memory_crud.go index 6caeb17..02904d9 100644 --- a/internal/store/memory_crud.go +++ b/internal/store/memory_crud.go @@ -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 diff --git a/internal/store/memory_firewall.go b/internal/store/memory_firewall.go index 30897a8..06cd818 100644 --- a/internal/store/memory_firewall.go +++ b/internal/store/memory_firewall.go @@ -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 diff --git a/internal/store/ownership.go b/internal/store/ownership.go new file mode 100644 index 0000000..0bf33f7 --- /dev/null +++ b/internal/store/ownership.go @@ -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 +} diff --git a/internal/store/ownership_test.go b/internal/store/ownership_test.go new file mode 100644 index 0000000..8118301 --- /dev/null +++ b/internal/store/ownership_test.go @@ -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) + } +} diff --git a/migrations/postgres/000029_created_by_user.down.sql b/migrations/postgres/000029_created_by_user.down.sql new file mode 100644 index 0000000..9e3784a --- /dev/null +++ b/migrations/postgres/000029_created_by_user.down.sql @@ -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; diff --git a/migrations/postgres/000029_created_by_user.up.sql b/migrations/postgres/000029_created_by_user.up.sql new file mode 100644 index 0000000..e191756 --- /dev/null +++ b/migrations/postgres/000029_created_by_user.up.sql @@ -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); diff --git a/migrations/sqlite/000029_created_by_user.down.sql b/migrations/sqlite/000029_created_by_user.down.sql new file mode 100644 index 0000000..56f8b3a --- /dev/null +++ b/migrations/sqlite/000029_created_by_user.down.sql @@ -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. diff --git a/migrations/sqlite/000029_created_by_user.up.sql b/migrations/sqlite/000029_created_by_user.up.sql new file mode 100644 index 0000000..66b2a8b --- /dev/null +++ b/migrations/sqlite/000029_created_by_user.up.sql @@ -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);