feat: OIDC-only auth, user-scoped keys/logs, admin group, 1yr log retention
This commit is contained in:
@@ -78,16 +78,11 @@ func run() error {
|
||||
|
||||
syncer := catalog.NewSyncer(st, cache)
|
||||
|
||||
// OIDC is optional; only initialised when the issuer is configured.
|
||||
var oidcV *oidcauth.Verifier
|
||||
if cfg.OIDC.Issuer != "" {
|
||||
v, err := oidcauth.New(ctx, cfg.OIDC.Issuer, cfg.OIDC.ClientID, cfg.OIDC.AdminGroup)
|
||||
if err != nil {
|
||||
return fmt.Errorf("init oidc: %w", err)
|
||||
}
|
||||
oidcV = v
|
||||
slog.Info("OIDC admin auth enabled", "issuer", cfg.OIDC.Issuer)
|
||||
oidcV, err := oidcauth.New(ctx, cfg.OIDC.Issuer, cfg.OIDC.ClientID, cfg.OIDC.AdminGroup)
|
||||
if err != nil {
|
||||
return fmt.Errorf("init oidc: %w", err)
|
||||
}
|
||||
slog.Info("OIDC auth enabled", "issuer", cfg.OIDC.Issuer)
|
||||
|
||||
limiter := ratelimit.New()
|
||||
|
||||
@@ -109,22 +104,35 @@ func run() error {
|
||||
|
||||
go syncer.Run(ctx, cfg.Catalog.SyncInterval, refresh)
|
||||
|
||||
// Purge request logs older than 1 year on a daily tick.
|
||||
go func() {
|
||||
tick := time.NewTicker(24 * time.Hour)
|
||||
defer tick.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-tick.C:
|
||||
purgeCtx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
_, _ = pool.Exec(purgeCtx, `DELETE FROM request_logs WHERE created_at < NOW() - INTERVAL '1 year'`)
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
deps := api.Deps{
|
||||
Store: st,
|
||||
Cache: cache,
|
||||
Manager: mgr,
|
||||
Cipher: cipher,
|
||||
AdminToken: cfg.Security.AdminToken,
|
||||
OIDCVerifier: oidcV,
|
||||
Limiter: limiter,
|
||||
SyncNow: syncNow,
|
||||
}
|
||||
if oidcV != nil {
|
||||
deps.OIDCClientID = cfg.OIDC.ClientID
|
||||
deps.OIDCClientSecret = cfg.OIDC.ClientSecret
|
||||
deps.OIDCAuthURL = oidcV.AuthURL()
|
||||
deps.OIDCTokenURL = oidcV.TokenURL()
|
||||
deps.PublicBaseURL = cfg.Server.PublicURL
|
||||
Store: st,
|
||||
Cache: cache,
|
||||
Manager: mgr,
|
||||
Cipher: cipher,
|
||||
OIDCVerifier: oidcV,
|
||||
OIDCClientID: cfg.OIDC.ClientID,
|
||||
OIDCClientSecret: cfg.OIDC.ClientSecret,
|
||||
OIDCAuthURL: oidcV.AuthURL(),
|
||||
OIDCTokenURL: oidcV.TokenURL(),
|
||||
PublicBaseURL: cfg.Server.PublicURL,
|
||||
Limiter: limiter,
|
||||
SyncNow: syncNow,
|
||||
}
|
||||
a := api.New(deps)
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState } from 'react'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
|
||||
import { getToken } from './lib/api'
|
||||
import { getToken, clearToken, api } from './lib/api'
|
||||
import type { CurrentUser } from './lib/types'
|
||||
import Login from './pages/Login'
|
||||
import Layout from './components/Layout'
|
||||
import Overview from './pages/Overview'
|
||||
@@ -14,6 +15,15 @@ import Settings from './pages/Settings'
|
||||
|
||||
export default function App() {
|
||||
const [authed, setAuthed] = useState(() => !!getToken())
|
||||
const [currentUser, setCurrentUser] = useState<CurrentUser | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!authed) return
|
||||
api.me().then(setCurrentUser).catch(() => {
|
||||
clearToken()
|
||||
setAuthed(false)
|
||||
})
|
||||
}, [authed])
|
||||
|
||||
if (!authed) {
|
||||
return <Login onLogin={() => setAuthed(true)} />
|
||||
@@ -22,17 +32,19 @@ export default function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/" element={<Layout />}>
|
||||
<Route path="/" element={<Layout currentUser={currentUser} />}>
|
||||
<Route index element={<Navigate to="/overview" replace />} />
|
||||
<Route path="overview" element={<Overview />} />
|
||||
<Route path="logs" element={<Logs />} />
|
||||
<Route path="keys" element={<ApiKeys />} />
|
||||
<Route path="providers" element={<Providers />} />
|
||||
<Route path="routing" element={<Routing />} />
|
||||
<Route path="analytics" element={<Analytics />} />
|
||||
<Route path="models" element={<Models />} />
|
||||
<Route path="settings" element={<Settings />} />
|
||||
<Route path="*" element={<Navigate to="/overview" replace />} />
|
||||
{currentUser?.is_admin && <>
|
||||
<Route path="providers" element={<Providers />} />
|
||||
<Route path="routing" element={<Routing />} />
|
||||
<Route path="models" element={<Models />} />
|
||||
<Route path="settings" element={<Settings />} />
|
||||
</>}
|
||||
<Route path="*" element={<Navigate to="/overview" replace />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
import { NavLink, Outlet, useLocation } from 'react-router-dom'
|
||||
import { clearToken } from '../lib/api'
|
||||
import type { CurrentUser } from '../lib/types'
|
||||
|
||||
const NAV = [
|
||||
const USER_NAV = [
|
||||
{ to: '/overview', label: 'Overview', icon: <IconGrid /> },
|
||||
{ to: '/logs', label: 'Request Logs', icon: <IconLogs /> },
|
||||
{ to: '/keys', label: 'API Keys', icon: <IconKey /> },
|
||||
{ to: '/analytics',label: 'Analytics', icon: <IconChart /> },
|
||||
]
|
||||
|
||||
const ADMIN_NAV = [
|
||||
{ to: '/providers',label: 'Providers', icon: <IconServer /> },
|
||||
{ to: '/routing', label: 'Routing Rules', icon: <IconRoute /> },
|
||||
{ to: '/analytics',label: 'Analytics', icon: <IconChart /> },
|
||||
{ to: '/models', label: 'Model Catalog', icon: <IconBox /> },
|
||||
]
|
||||
|
||||
@@ -22,9 +26,10 @@ function navStyle(active: boolean) {
|
||||
}
|
||||
}
|
||||
|
||||
export default function Layout() {
|
||||
export default function Layout({ currentUser }: { currentUser: CurrentUser | null }) {
|
||||
const loc = useLocation()
|
||||
const section = NAV.find(n => loc.pathname.startsWith(n.to))?.label ?? 'Settings'
|
||||
const allNav = [...USER_NAV, ...(currentUser?.is_admin ? ADMIN_NAV : [])]
|
||||
const section = allNav.find(n => loc.pathname.startsWith(n.to))?.label ?? 'Settings'
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', height: '100vh', overflow: 'hidden', background: '#09090b' }}>
|
||||
@@ -73,12 +78,23 @@ export default function Layout() {
|
||||
<div style={{ fontSize: 10, fontWeight: 600, letterSpacing: '0.09em', color: 'rgba(237,237,240,0.22)', padding: '0 8px', margin: '2px 0 4px', textTransform: 'uppercase' }}>
|
||||
Main
|
||||
</div>
|
||||
{NAV.map(({ to, label, icon }) => (
|
||||
{USER_NAV.map(({ to, label, icon }) => (
|
||||
<NavLink key={to} to={to} style={({ isActive }) => navStyle(isActive)}>
|
||||
{icon}
|
||||
{label}
|
||||
</NavLink>
|
||||
))}
|
||||
{currentUser?.is_admin && <>
|
||||
<div style={{ fontSize: 10, fontWeight: 600, letterSpacing: '0.09em', color: 'rgba(237,237,240,0.22)', padding: '0 8px', margin: '8px 0 4px', textTransform: 'uppercase' }}>
|
||||
Admin
|
||||
</div>
|
||||
{ADMIN_NAV.map(({ to, label, icon }) => (
|
||||
<NavLink key={to} to={to} style={({ isActive }) => navStyle(isActive)}>
|
||||
{icon}
|
||||
{label}
|
||||
</NavLink>
|
||||
))}
|
||||
</>}
|
||||
</nav>
|
||||
|
||||
{/* Bottom */}
|
||||
@@ -93,9 +109,11 @@ export default function Layout() {
|
||||
background: 'linear-gradient(135deg,#7c3aed,#2563eb)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: 11.5, fontWeight: 600, color: 'white',
|
||||
}}>A</div>
|
||||
}}>{(currentUser?.name || currentUser?.email || 'U')[0].toUpperCase()}</div>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ fontSize: 12.5, fontWeight: 500, color: '#ededf0' }}>admin</div>
|
||||
<div style={{ fontSize: 12.5, fontWeight: 500, color: '#ededf0', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{currentUser?.name || currentUser?.email || '…'}
|
||||
</div>
|
||||
<button onClick={() => { clearToken(); window.location.reload() }}
|
||||
style={{ fontSize: 10, color: 'rgba(237,237,240,0.3)', background: 'none', border: 'none', cursor: 'pointer', padding: 0 }}>
|
||||
Sign out
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
import type {
|
||||
Provider, VirtualKey, AdminModel, RequestLog, KeyStat,
|
||||
Provider, VirtualKey, AdminModel, RequestLog, KeyStat, CurrentUser,
|
||||
CreateProviderParams, CreateKeyParams, ModelOverride,
|
||||
} from './types'
|
||||
|
||||
const BASE = '/api/admin'
|
||||
const BASE_API = '/api'
|
||||
|
||||
export function getToken() { return localStorage.getItem('admin_token') ?? '' }
|
||||
export function setToken(t: string) { localStorage.setItem('admin_token', t) }
|
||||
export function clearToken() { localStorage.removeItem('admin_token') }
|
||||
|
||||
async function req<T>(path: string, opts?: RequestInit): Promise<T> {
|
||||
const res = await fetch(BASE + path, {
|
||||
async function req<T>(path: string, opts?: RequestInit, base = BASE): Promise<T> {
|
||||
const res = await fetch(base + path, {
|
||||
...opts,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -71,4 +72,5 @@ export const api = {
|
||||
return req<KeyStat[]>('/stats/keys' + qs)
|
||||
},
|
||||
},
|
||||
me: () => req<CurrentUser>('/me', undefined, BASE_API),
|
||||
}
|
||||
|
||||
@@ -65,6 +65,13 @@ export interface KeyStat {
|
||||
errors: number
|
||||
}
|
||||
|
||||
export interface CurrentUser {
|
||||
id: string
|
||||
email: string
|
||||
name: string
|
||||
is_admin: boolean
|
||||
}
|
||||
|
||||
export interface CatalogEntry {
|
||||
id: string
|
||||
object: string
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect } from 'react'
|
||||
import { setToken } from '../lib/api'
|
||||
|
||||
interface AuthConfig {
|
||||
oidc_enabled: boolean
|
||||
static_token: boolean
|
||||
}
|
||||
|
||||
const LOGO = (
|
||||
<div style={{
|
||||
width: 34, height: 34, borderRadius: 9,
|
||||
@@ -25,76 +20,28 @@ const LOGO = (
|
||||
)
|
||||
|
||||
export default function Login({ onLogin }: { onLogin: () => void }) {
|
||||
const [cfg, setCfg] = useState<AuthConfig | null>(null)
|
||||
const [token, setTok] = useState('')
|
||||
const [err, setErr] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
// Check if returning from OIDC callback — server puts the token in the fragment.
|
||||
const hash = new URLSearchParams(window.location.hash.slice(1))
|
||||
const fragToken = hash.get('token')
|
||||
if (fragToken) {
|
||||
window.history.replaceState({}, '', '/')
|
||||
setToken(decodeURIComponent(fragToken))
|
||||
onLogin()
|
||||
return
|
||||
}
|
||||
|
||||
fetch('/api/auth/config')
|
||||
.then(r => r.json())
|
||||
.then(setCfg)
|
||||
.catch(() => setCfg({ oidc_enabled: false, static_token: true }))
|
||||
}, [])
|
||||
|
||||
async function submitToken(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setErr('')
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await fetch('/api/admin/providers', {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
if (res.status === 401 || res.status === 403) { setErr('Invalid token'); return }
|
||||
if (!res.ok && res.status !== 404) { setErr('Server error — is the backend running?'); return }
|
||||
setToken(token)
|
||||
onLogin()
|
||||
} catch {
|
||||
setErr('Cannot reach server')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const card = (children: React.ReactNode) => (
|
||||
return (
|
||||
<div style={{ minHeight: '100vh', background: '#09090b', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<div style={{ width: 360, background: '#111115', border: '1px solid rgba(255,255,255,0.07)', borderRadius: 12, padding: '32px 28px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 28 }}>
|
||||
{LOGO}
|
||||
<div>
|
||||
<div style={{ fontSize: 14, fontWeight: 600, color: '#f0eeff' }}>Local Model Routex</div>
|
||||
<div style={{ fontSize: 11, color: 'rgba(237,237,240,0.35)' }}>Admin Dashboard</div>
|
||||
<div style={{ fontSize: 11, color: 'rgba(237,237,240,0.35)' }}>Dashboard</div>
|
||||
</div>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
if (!cfg) return card(
|
||||
<div style={{ textAlign: 'center', fontSize: 13, color: 'rgba(237,237,240,0.35)' }}>Loading…</div>
|
||||
)
|
||||
|
||||
return card(
|
||||
<>
|
||||
{err && (
|
||||
<div style={{ background: 'rgba(239,68,68,0.1)', border: '1px solid rgba(239,68,68,0.2)', borderRadius: 7, padding: '9px 12px', fontSize: 12, color: '#f87171', marginBottom: 16 }}>
|
||||
{err}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{cfg.oidc_enabled && (
|
||||
<a href="/auth/login" style={{ textDecoration: 'none', display: 'block', marginBottom: cfg.static_token ? 16 : 0 }}>
|
||||
<a href="/auth/login" style={{ textDecoration: 'none', display: 'block' }}>
|
||||
<button style={{
|
||||
width: '100%', background: '#7c3aed', border: 'none',
|
||||
borderRadius: 7, padding: '10px 0', fontSize: 13.5,
|
||||
@@ -108,47 +55,7 @@ export default function Login({ onLogin }: { onLogin: () => void }) {
|
||||
Continue with Authentik
|
||||
</button>
|
||||
</a>
|
||||
)}
|
||||
|
||||
{cfg.oidc_enabled && cfg.static_token && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 16 }}>
|
||||
<div style={{ flex: 1, height: 1, background: 'rgba(255,255,255,0.07)' }}/>
|
||||
<span style={{ fontSize: 11, color: 'rgba(237,237,240,0.28)' }}>or</span>
|
||||
<div style={{ flex: 1, height: 1, background: 'rgba(255,255,255,0.07)' }}/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{cfg.static_token && (
|
||||
<form onSubmit={submitToken}>
|
||||
<label style={{ display: 'block', fontSize: 12, fontWeight: 500, color: 'rgba(237,237,240,0.55)', marginBottom: 6 }}>
|
||||
Admin token
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={token}
|
||||
onChange={e => setTok(e.target.value)}
|
||||
placeholder="sk-…"
|
||||
autoFocus={!cfg.oidc_enabled}
|
||||
style={{
|
||||
width: '100%', background: 'rgba(0,0,0,0.28)',
|
||||
border: '1px solid rgba(255,255,255,0.09)', borderRadius: 7,
|
||||
padding: '9px 12px', fontSize: 13, color: '#ededf0',
|
||||
outline: 'none', fontFamily: "'DM Mono',monospace", marginBottom: 12,
|
||||
}}
|
||||
/>
|
||||
<button type="submit" disabled={!token || loading} style={{
|
||||
width: '100%',
|
||||
background: cfg.oidc_enabled ? 'rgba(124,58,237,0.15)' : '#7c3aed',
|
||||
border: cfg.oidc_enabled ? '1px solid rgba(124,58,237,0.3)' : 'none',
|
||||
borderRadius: 7, padding: '9px 0', fontSize: 13.5,
|
||||
fontWeight: 500,
|
||||
color: cfg.oidc_enabled ? '#c4b5fd' : 'white',
|
||||
cursor: 'pointer', opacity: !token || loading ? 0.5 : 1,
|
||||
}}>
|
||||
{loading ? 'Verifying…' : 'Sign in'}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -123,8 +123,15 @@ func (a *API) adminCreateKey(w http.ResponseWriter, r *http.Request) {
|
||||
expires = &t
|
||||
}
|
||||
|
||||
// Bind the key to the authenticated user.
|
||||
au := userFrom(r.Context())
|
||||
var userID *uuid.UUID
|
||||
if au != nil {
|
||||
userID = &au.User.ID
|
||||
}
|
||||
|
||||
id, err := a.Store.CreateVirtualKey(r.Context(), store.CreateVirtualKeyParams{
|
||||
Name: in.Name, KeyHash: sum[:], KeyPrefix: prefix,
|
||||
Name: in.Name, KeyHash: sum[:], KeyPrefix: prefix, UserID: userID,
|
||||
AllowAllModels: in.AllowAllModels, ExpiresAt: expires, Rules: in.Rules,
|
||||
})
|
||||
if err != nil {
|
||||
@@ -136,7 +143,13 @@ func (a *API) adminCreateKey(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (a *API) adminListKeys(w http.ResponseWriter, r *http.Request) {
|
||||
keys, err := a.Store.ListVirtualKeys(r.Context())
|
||||
// Admins see all keys; regular users see only their own.
|
||||
au := userFrom(r.Context())
|
||||
var userID *uuid.UUID
|
||||
if au != nil && !au.IsAdmin {
|
||||
userID = &au.User.ID
|
||||
}
|
||||
keys, err := a.Store.ListVirtualKeys(r.Context(), userID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
@@ -239,9 +252,16 @@ func (a *API) adminPutSetting(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (a *API) adminListLogs(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query()
|
||||
// Admins see all logs; regular users see only their own keys' logs.
|
||||
au := userFrom(r.Context())
|
||||
var userID *uuid.UUID
|
||||
if au != nil && !au.IsAdmin {
|
||||
userID = &au.User.ID
|
||||
}
|
||||
p := store.ListLogsParams{
|
||||
Provider: q.Get("provider"),
|
||||
KeyPrefix: q.Get("key"),
|
||||
UserID: userID,
|
||||
Limit: 100,
|
||||
}
|
||||
if s := q.Get("status"); s == "error" {
|
||||
@@ -272,7 +292,12 @@ func (a *API) adminKeyStats(w http.ResponseWriter, r *http.Request) {
|
||||
since = t
|
||||
}
|
||||
}
|
||||
stats, err := a.Store.KeyStats(r.Context(), since)
|
||||
au := userFrom(r.Context())
|
||||
var userID *uuid.UUID
|
||||
if au != nil && !au.IsAdmin {
|
||||
userID = &au.User.ID
|
||||
}
|
||||
stats, err := a.Store.KeyStats(r.Context(), since, userID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
|
||||
@@ -4,20 +4,42 @@ package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"git.govekar.net/LovroG05/localmodelroutex/internal/catalog"
|
||||
"git.govekar.net/LovroG05/localmodelroutex/internal/crypto"
|
||||
"git.govekar.net/LovroG05/localmodelroutex/internal/oidcauth"
|
||||
"git.govekar.net/LovroG05/localmodelroutex/internal/providermgr"
|
||||
"git.govekar.net/LovroG05/localmodelroutex/internal/ratelimit"
|
||||
"git.govekar.net/LovroG05/localmodelroutex/internal/store"
|
||||
)
|
||||
|
||||
// oidcVerifier validates OIDC tokens for admin access.
|
||||
// Satisfied by *oidcauth.Verifier; nil means OIDC is not configured.
|
||||
// oidcVerifier validates OIDC tokens.
|
||||
// Satisfied by *oidcauth.Verifier.
|
||||
type oidcVerifier interface {
|
||||
Verify(ctx context.Context, rawToken string) (*oidcauth.Claims, error)
|
||||
IsAdmin(c *oidcauth.Claims) bool
|
||||
VerifyAdmin(ctx context.Context, rawToken string) error
|
||||
AuthURL() string
|
||||
TokenURL() string
|
||||
}
|
||||
|
||||
// ctxKeyUser is the context key for the authenticated user.
|
||||
type ctxKeyUser struct{}
|
||||
|
||||
// authedUser holds the resolved user and their admin status for the current request.
|
||||
type authedUser struct {
|
||||
User store.User
|
||||
IsAdmin bool
|
||||
}
|
||||
|
||||
// userFrom retrieves the authenticated user from the request context.
|
||||
// Returns nil when called outside of requireAuth.
|
||||
func userFrom(ctx context.Context) *authedUser {
|
||||
u, _ := ctx.Value(ctxKeyUser{}).(*authedUser)
|
||||
return u
|
||||
}
|
||||
|
||||
// Deps are the dependencies the API handlers need.
|
||||
@@ -26,9 +48,8 @@ type Deps struct {
|
||||
Cache *catalog.Cache
|
||||
Manager *providermgr.Manager
|
||||
Cipher *crypto.Cipher
|
||||
AdminToken string
|
||||
OIDCVerifier oidcVerifier // nil when OIDC is not configured
|
||||
OIDCClientID string // empty when OIDC is not configured
|
||||
OIDCVerifier oidcVerifier
|
||||
OIDCClientID string
|
||||
OIDCClientSecret string
|
||||
OIDCAuthURL string // authorization_endpoint from discovery
|
||||
OIDCTokenURL string // token_endpoint from discovery
|
||||
@@ -61,24 +82,48 @@ func (a *API) Mount(r chi.Router) {
|
||||
})
|
||||
|
||||
// Public auth endpoints — no auth middleware.
|
||||
r.Get("/api/auth/config", a.authConfig)
|
||||
r.Get("/auth/login", a.oidcLogin)
|
||||
r.Get("/auth/callback", a.oidcCallback)
|
||||
|
||||
r.Route("/api/admin", func(r chi.Router) {
|
||||
r.Use(a.adminAuth)
|
||||
r.Get("/providers", a.adminListProviders)
|
||||
r.Post("/providers", a.adminCreateProvider)
|
||||
r.Delete("/providers/{id}", a.adminDeleteProvider)
|
||||
r.Get("/keys", a.adminListKeys)
|
||||
r.Post("/keys", a.adminCreateKey)
|
||||
r.Delete("/keys/{id}", a.adminRevokeKey)
|
||||
r.Get("/logs", a.adminListLogs)
|
||||
// Authenticated user endpoints.
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(a.requireAuth)
|
||||
r.Get("/api/me", a.apiMe)
|
||||
|
||||
r.Route("/api/admin", func(r chi.Router) {
|
||||
// Keys and logs are user-scoped (admins see all via nil filter).
|
||||
r.Get("/keys", a.adminListKeys)
|
||||
r.Post("/keys", a.adminCreateKey)
|
||||
r.Delete("/keys/{id}", a.adminRevokeKey)
|
||||
r.Get("/logs", a.adminListLogs)
|
||||
r.Get("/stats/keys", a.adminKeyStats)
|
||||
r.Get("/models", a.adminListModels)
|
||||
r.Patch("/models/{id}", a.adminUpdateModel)
|
||||
r.Get("/settings", a.adminGetSettings)
|
||||
r.Put("/settings/{key}", a.adminPutSetting)
|
||||
r.Post("/catalog/refresh", a.adminRefreshCatalog)
|
||||
|
||||
// The following routes require admin group membership.
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(a.requireAdmin)
|
||||
r.Get("/providers", a.adminListProviders)
|
||||
r.Post("/providers", a.adminCreateProvider)
|
||||
r.Delete("/providers/{id}", a.adminDeleteProvider)
|
||||
r.Get("/models", a.adminListModels)
|
||||
r.Patch("/models/{id}", a.adminUpdateModel)
|
||||
r.Get("/settings", a.adminGetSettings)
|
||||
r.Put("/settings/{key}", a.adminPutSetting)
|
||||
r.Post("/catalog/refresh", a.adminRefreshCatalog)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// Legacy public endpoint — tells the dashboard OIDC is configured.
|
||||
r.Get("/api/auth/config", a.authConfig)
|
||||
}
|
||||
|
||||
// apiMe returns the current user's profile and admin status.
|
||||
func (a *API) apiMe(w http.ResponseWriter, r *http.Request) {
|
||||
u := userFrom(r.Context())
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"id": u.User.ID,
|
||||
"email": u.User.Email,
|
||||
"name": u.User.Name,
|
||||
"is_admin": u.IsAdmin,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -6,24 +6,19 @@ import (
|
||||
)
|
||||
|
||||
type authConfigResponse struct {
|
||||
OIDCEnabled bool `json:"oidc_enabled"`
|
||||
ClientID string `json:"client_id,omitempty"`
|
||||
AuthURL string `json:"auth_url,omitempty"`
|
||||
TokenURL string `json:"token_url,omitempty"`
|
||||
StaticToken bool `json:"static_token"`
|
||||
OIDCEnabled bool `json:"oidc_enabled"`
|
||||
ClientID string `json:"client_id,omitempty"`
|
||||
AuthURL string `json:"auth_url,omitempty"`
|
||||
TokenURL string `json:"token_url,omitempty"`
|
||||
}
|
||||
|
||||
// authConfig is a public endpoint (no auth) that tells the dashboard which
|
||||
// login methods are available and the OIDC endpoints to use for PKCE flow.
|
||||
// authConfig tells the dashboard that OIDC is the only login method.
|
||||
func (a *API) authConfig(w http.ResponseWriter, r *http.Request) {
|
||||
resp := authConfigResponse{
|
||||
OIDCEnabled: a.OIDCVerifier != nil,
|
||||
StaticToken: a.AdminToken != "",
|
||||
}
|
||||
if a.OIDCVerifier != nil {
|
||||
resp.ClientID = a.OIDCClientID
|
||||
resp.AuthURL = a.OIDCAuthURL
|
||||
resp.TokenURL = a.OIDCTokenURL
|
||||
OIDCEnabled: true,
|
||||
ClientID: a.OIDCClientID,
|
||||
AuthURL: a.OIDCAuthURL,
|
||||
TokenURL: a.OIDCTokenURL,
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(resp)
|
||||
|
||||
@@ -3,7 +3,6 @@ package api
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -50,29 +49,36 @@ func keyFrom(ctx context.Context) *store.VirtualKey {
|
||||
return k
|
||||
}
|
||||
|
||||
// adminAuth guards admin routes. OIDC is used when configured; otherwise a
|
||||
// static bearer token is required. Returns 503 when neither is configured.
|
||||
func (a *API) adminAuth(next http.Handler) http.Handler {
|
||||
// requireAuth verifies the Bearer token as an OIDC ID token, upserts the user
|
||||
// in the DB, and stores the authedUser in the request context.
|
||||
func (a *API) requireAuth(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
tok := bearer(r)
|
||||
if tok == "" {
|
||||
writeError(w, http.StatusUnauthorized, "missing bearer token")
|
||||
return
|
||||
}
|
||||
if a.OIDCVerifier != nil {
|
||||
if err := a.OIDCVerifier.VerifyAdmin(r.Context(), tok); err != nil {
|
||||
writeError(w, http.StatusUnauthorized, "unauthorized: "+err.Error())
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
claims, err := a.OIDCVerifier.Verify(r.Context(), tok)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusUnauthorized, "unauthorized: "+err.Error())
|
||||
return
|
||||
}
|
||||
if a.AdminToken == "" {
|
||||
writeError(w, http.StatusServiceUnavailable, "admin API disabled (configure OIDC or set GATEWAY_SECURITY_ADMIN_TOKEN)")
|
||||
u, err := a.Store.UpsertUser(r.Context(), claims.Subject, claims.Email, claims.Name)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "user lookup failed")
|
||||
return
|
||||
}
|
||||
if subtle.ConstantTimeCompare([]byte(tok), []byte(a.AdminToken)) != 1 {
|
||||
writeError(w, http.StatusUnauthorized, "unauthorized")
|
||||
au := &authedUser{User: u, IsAdmin: a.OIDCVerifier.IsAdmin(claims)}
|
||||
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), ctxKeyUser{}, au)))
|
||||
})
|
||||
}
|
||||
|
||||
// requireAdmin rejects requests from non-admin users. Must be composed after requireAuth.
|
||||
func (a *API) requireAdmin(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
u := userFrom(r.Context())
|
||||
if u == nil || !u.IsAdmin {
|
||||
writeError(w, http.StatusForbidden, "admin access required")
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
|
||||
@@ -74,12 +74,19 @@ func (a *API) oidcCallback(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Verify signature + group membership before handing the token to the browser.
|
||||
if err := a.OIDCVerifier.VerifyAdmin(r.Context(), idToken); err != nil {
|
||||
// Verify signature and extract claims; any valid OIDC user may log in.
|
||||
claims, err := a.OIDCVerifier.Verify(r.Context(), idToken)
|
||||
if err != nil {
|
||||
http.Error(w, "unauthorized: "+err.Error(), http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
// Provision or update the user record on every login.
|
||||
if _, err := a.Store.UpsertUser(r.Context(), claims.Subject, claims.Email, claims.Name); err != nil {
|
||||
http.Error(w, "user provisioning failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Redirect to the SPA root with the token in the URL fragment.
|
||||
// PublicBaseURL is set from config — never from request headers — so a
|
||||
// forged Host / X-Forwarded-Host cannot redirect the token elsewhere.
|
||||
|
||||
@@ -42,7 +42,6 @@ type OIDCConfig struct {
|
||||
|
||||
type SecurityConfig struct {
|
||||
EncryptionKey string // hex-encoded 32 bytes for AES-256-GCM
|
||||
AdminToken string // bearer token guarding /api/admin (interim, pre-OIDC)
|
||||
}
|
||||
|
||||
func Load() (*Config, error) {
|
||||
@@ -83,7 +82,6 @@ func Load() (*Config, error) {
|
||||
},
|
||||
Security: SecurityConfig{
|
||||
EncryptionKey: v.GetString("security.encryption_key"),
|
||||
AdminToken: v.GetString("security.admin_token"),
|
||||
},
|
||||
Catalog: CatalogConfig{
|
||||
SyncInterval: v.GetDuration("catalog.sync_interval"),
|
||||
@@ -103,26 +101,26 @@ func (c *Config) validate() error {
|
||||
if len(c.Security.EncryptionKey) != 64 {
|
||||
return fmt.Errorf("security.encryption_key must be 64 hex chars (32 bytes)")
|
||||
}
|
||||
// OIDC is optional; if the issuer is set the other two fields are required.
|
||||
if c.OIDC.Issuer != "" {
|
||||
var missing []string
|
||||
if c.OIDC.ClientID == "" {
|
||||
missing = append(missing, "oidc.client_id")
|
||||
}
|
||||
if c.OIDC.ClientSecret == "" {
|
||||
missing = append(missing, "oidc.client_secret")
|
||||
}
|
||||
if c.OIDC.AdminGroup == "" {
|
||||
missing = append(missing, "oidc.admin_group")
|
||||
}
|
||||
// PublicURL pins the OAuth redirect_uri and the post-login redirect so
|
||||
// neither can be overridden by a forged Host / X-Forwarded-Host header.
|
||||
if c.Server.PublicURL == "" {
|
||||
missing = append(missing, "server.public_url")
|
||||
}
|
||||
if len(missing) > 0 {
|
||||
return fmt.Errorf("oidc.issuer is set but missing: %s", strings.Join(missing, ", "))
|
||||
}
|
||||
var missing []string
|
||||
if c.OIDC.Issuer == "" {
|
||||
missing = append(missing, "oidc.issuer")
|
||||
}
|
||||
if c.OIDC.ClientID == "" {
|
||||
missing = append(missing, "oidc.client_id")
|
||||
}
|
||||
if c.OIDC.ClientSecret == "" {
|
||||
missing = append(missing, "oidc.client_secret")
|
||||
}
|
||||
if c.OIDC.AdminGroup == "" {
|
||||
missing = append(missing, "oidc.admin_group")
|
||||
}
|
||||
// PublicURL pins the OAuth redirect_uri and the post-login redirect so
|
||||
// neither can be overridden by a forged Host / X-Forwarded-Host header.
|
||||
if c.Server.PublicURL == "" {
|
||||
missing = append(missing, "server.public_url")
|
||||
}
|
||||
if len(missing) > 0 {
|
||||
return fmt.Errorf("missing required OIDC config: %s", strings.Join(missing, ", "))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -8,6 +8,14 @@ import (
|
||||
"github.com/coreos/go-oidc/v3/oidc"
|
||||
)
|
||||
|
||||
// Claims holds the extracted payload from a verified ID token.
|
||||
type Claims struct {
|
||||
Subject string
|
||||
Email string
|
||||
Name string
|
||||
Groups []string
|
||||
}
|
||||
|
||||
// Verifier validates OIDC JWTs against a provider's public keys and checks
|
||||
// that the caller belongs to the configured admin group.
|
||||
type Verifier struct {
|
||||
@@ -39,23 +47,47 @@ func (v *Verifier) AuthURL() string { return v.authURL }
|
||||
// TokenURL returns the provider's token endpoint.
|
||||
func (v *Verifier) TokenURL() string { return v.tokenURL }
|
||||
|
||||
// VerifyAdmin checks that rawToken is a valid JWT from this issuer and that its
|
||||
// "groups" claim includes the configured admin group.
|
||||
func (v *Verifier) VerifyAdmin(ctx context.Context, rawToken string) error {
|
||||
// Verify checks that rawToken is a valid JWT from this issuer and returns the
|
||||
// extracted claims. It does NOT check group membership.
|
||||
func (v *Verifier) Verify(ctx context.Context, rawToken string) (*Claims, error) {
|
||||
token, err := v.v.Verify(ctx, rawToken)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid token: %w", err)
|
||||
return nil, fmt.Errorf("invalid token: %w", err)
|
||||
}
|
||||
var claims struct {
|
||||
var raw struct {
|
||||
Email string `json:"email"`
|
||||
Name string `json:"name"`
|
||||
Groups []string `json:"groups"`
|
||||
}
|
||||
if err := token.Claims(&claims); err != nil {
|
||||
return fmt.Errorf("claims extraction failed: %w", err)
|
||||
if err := token.Claims(&raw); err != nil {
|
||||
return nil, fmt.Errorf("claims extraction failed: %w", err)
|
||||
}
|
||||
for _, g := range claims.Groups {
|
||||
return &Claims{
|
||||
Subject: token.Subject,
|
||||
Email: raw.Email,
|
||||
Name: raw.Name,
|
||||
Groups: raw.Groups,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// IsAdmin reports whether the claims include the configured admin group.
|
||||
func (v *Verifier) IsAdmin(c *Claims) bool {
|
||||
for _, g := range c.Groups {
|
||||
if g == v.adminGroup {
|
||||
return nil
|
||||
return true
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("not a member of %q", v.adminGroup)
|
||||
return false
|
||||
}
|
||||
|
||||
// VerifyAdmin is a convenience method that combines Verify and IsAdmin.
|
||||
func (v *Verifier) VerifyAdmin(ctx context.Context, rawToken string) error {
|
||||
c, err := v.Verify(ctx, rawToken)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !v.IsAdmin(c) {
|
||||
return fmt.Errorf("not a member of %q", v.adminGroup)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -90,10 +90,11 @@ type RequestLogListRow struct {
|
||||
|
||||
// ListLogsParams filters the request_logs query.
|
||||
type ListLogsParams struct {
|
||||
Provider string // empty = all
|
||||
KeyPrefix string // empty = all
|
||||
StatusGte int // 0 = all; e.g. 500 filters ≥500
|
||||
StatusLt int // 0 = no upper bound
|
||||
Provider string // empty = all
|
||||
KeyPrefix string // empty = all
|
||||
UserID *uuid.UUID // non-nil = only logs for keys owned by this user
|
||||
StatusGte int // 0 = all; e.g. 500 filters ≥500
|
||||
StatusLt int // 0 = no upper bound
|
||||
Since time.Time
|
||||
Limit int
|
||||
Offset int
|
||||
@@ -119,12 +120,13 @@ func (s *Store) ListRequestLogs(ctx context.Context, p ListLogsParams) ([]Reques
|
||||
LEFT JOIN request_log_content rlc ON rlc.request_log_id = rl.id
|
||||
WHERE ($1 = '' OR rl.provider_name = $1)
|
||||
AND ($2 = '' OR rl.key_prefix = $2)
|
||||
AND ($3 = 0 OR rl.status_code >= $3)
|
||||
AND ($4 = 0 OR rl.status_code < $4)
|
||||
AND ($5::timestamptz IS NULL OR rl.created_at >= $5)
|
||||
AND ($3::uuid IS NULL OR rl.virtual_key_id IN (SELECT id FROM virtual_keys WHERE user_id = $3))
|
||||
AND ($4 = 0 OR rl.status_code >= $4)
|
||||
AND ($5 = 0 OR rl.status_code < $5)
|
||||
AND ($6::timestamptz IS NULL OR rl.created_at >= $6)
|
||||
ORDER BY rl.created_at DESC
|
||||
LIMIT $6 OFFSET $7`,
|
||||
p.Provider, p.KeyPrefix, p.StatusGte, p.StatusLt, since, p.Limit, p.Offset,
|
||||
LIMIT $7 OFFSET $8`,
|
||||
p.Provider, p.KeyPrefix, p.UserID, p.StatusGte, p.StatusLt, since, p.Limit, p.Offset,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -159,8 +161,9 @@ type KeyStat struct {
|
||||
Errors int `json:"errors"`
|
||||
}
|
||||
|
||||
// KeyStats returns per-key aggregate stats since the given time.
|
||||
func (s *Store) KeyStats(ctx context.Context, since time.Time) ([]KeyStat, error) {
|
||||
// KeyStats returns per-key aggregate stats since the given time. When userID is
|
||||
// non-nil only that user's keys are included.
|
||||
func (s *Store) KeyStats(ctx context.Context, since time.Time, userID *uuid.UUID) ([]KeyStat, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT COALESCE(key_prefix, '(no key)'),
|
||||
COUNT(*)::int,
|
||||
@@ -171,9 +174,10 @@ func (s *Store) KeyStats(ctx context.Context, since time.Time) ([]KeyStat, error
|
||||
SUM(CASE WHEN status_code >= 400 THEN 1 ELSE 0 END)::int
|
||||
FROM request_logs
|
||||
WHERE created_at >= $1
|
||||
AND ($2::uuid IS NULL OR virtual_key_id IN (SELECT id FROM virtual_keys WHERE user_id = $2))
|
||||
GROUP BY key_prefix
|
||||
ORDER BY SUM(total_tokens) DESC`,
|
||||
since,
|
||||
since, userID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
50
backend/internal/store/users.go
Normal file
50
backend/internal/store/users.go
Normal file
@@ -0,0 +1,50 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// User mirrors the users table row.
|
||||
type User struct {
|
||||
ID uuid.UUID
|
||||
Subject string
|
||||
Email string
|
||||
Name string
|
||||
CreatedAt time.Time
|
||||
LastLoginAt *time.Time
|
||||
}
|
||||
|
||||
// UpsertUser inserts or updates the user identified by subject, returning the
|
||||
// current row. Call on every successful OIDC login.
|
||||
func (s *Store) UpsertUser(ctx context.Context, subject, email, name string) (User, error) {
|
||||
var u User
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
INSERT INTO users (subject, email, name, last_login_at)
|
||||
VALUES ($1, $2, $3, now())
|
||||
ON CONFLICT (subject) DO UPDATE
|
||||
SET email = EXCLUDED.email,
|
||||
name = EXCLUDED.name,
|
||||
last_login_at = now()
|
||||
RETURNING id, subject, COALESCE(email,''), COALESCE(name,''), created_at, last_login_at`,
|
||||
subject, nullString(email), nullString(name),
|
||||
).Scan(&u.ID, &u.Subject, &u.Email, &u.Name, &u.CreatedAt, &u.LastLoginAt)
|
||||
return u, err
|
||||
}
|
||||
|
||||
// GetUserBySubject fetches a user by OIDC subject. Returns (zero, nil) when not found.
|
||||
func (s *Store) GetUserBySubject(ctx context.Context, subject string) (User, error) {
|
||||
var u User
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
SELECT id, subject, COALESCE(email,''), COALESCE(name,''), created_at, last_login_at
|
||||
FROM users WHERE subject = $1`, subject,
|
||||
).Scan(&u.ID, &u.Subject, &u.Email, &u.Name, &u.CreatedAt, &u.LastLoginAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return User{}, nil
|
||||
}
|
||||
return u, err
|
||||
}
|
||||
@@ -15,6 +15,7 @@ type VirtualKey struct {
|
||||
Name string
|
||||
KeyPrefix string
|
||||
Type string
|
||||
UserID *uuid.UUID
|
||||
AllowAllModels bool
|
||||
GroupID *uuid.UUID
|
||||
ExpiresAt *time.Time
|
||||
@@ -28,9 +29,9 @@ type VirtualKey struct {
|
||||
func (s *Store) GetVirtualKeyByHash(ctx context.Context, hash []byte) (*VirtualKey, error) {
|
||||
var k VirtualKey
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
SELECT id, COALESCE(name,''), key_prefix, type, allow_all_models, group_id, expires_at, revoked_at
|
||||
SELECT id, COALESCE(name,''), key_prefix, type, user_id, allow_all_models, group_id, expires_at, revoked_at
|
||||
FROM virtual_keys WHERE key_hash = $1`, hash,
|
||||
).Scan(&k.ID, &k.Name, &k.KeyPrefix, &k.Type, &k.AllowAllModels, &k.GroupID, &k.ExpiresAt, &k.RevokedAt)
|
||||
).Scan(&k.ID, &k.Name, &k.KeyPrefix, &k.Type, &k.UserID, &k.AllowAllModels, &k.GroupID, &k.ExpiresAt, &k.RevokedAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -58,6 +59,7 @@ type CreateVirtualKeyParams struct {
|
||||
Name string
|
||||
KeyHash []byte
|
||||
KeyPrefix string
|
||||
UserID *uuid.UUID
|
||||
AllowAllModels bool
|
||||
ExpiresAt *time.Time
|
||||
Rules []string
|
||||
@@ -72,9 +74,9 @@ func (s *Store) CreateVirtualKey(ctx context.Context, p CreateVirtualKeyParams)
|
||||
|
||||
var id uuid.UUID
|
||||
if err := tx.QueryRow(ctx, `
|
||||
INSERT INTO virtual_keys (name, key_hash, key_prefix, type, allow_all_models, expires_at)
|
||||
VALUES ($1, $2, $3, 'standard', $4, $5) RETURNING id`,
|
||||
nullString(p.Name), p.KeyHash, p.KeyPrefix, p.AllowAllModels, p.ExpiresAt,
|
||||
INSERT INTO virtual_keys (name, key_hash, key_prefix, type, user_id, allow_all_models, expires_at)
|
||||
VALUES ($1, $2, $3, 'standard', $4, $5, $6) RETURNING id`,
|
||||
nullString(p.Name), p.KeyHash, p.KeyPrefix, p.UserID, p.AllowAllModels, p.ExpiresAt,
|
||||
).Scan(&id); err != nil {
|
||||
return uuid.Nil, err
|
||||
}
|
||||
@@ -89,10 +91,14 @@ func (s *Store) CreateVirtualKey(ctx context.Context, p CreateVirtualKeyParams)
|
||||
return id, tx.Commit(ctx)
|
||||
}
|
||||
|
||||
func (s *Store) ListVirtualKeys(ctx context.Context) ([]VirtualKey, error) {
|
||||
// ListVirtualKeys returns virtual keys. When userID is non-nil only that user's
|
||||
// keys are returned; pass nil to list all (admin view).
|
||||
func (s *Store) ListVirtualKeys(ctx context.Context, userID *uuid.UUID) ([]VirtualKey, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT id, COALESCE(name,''), key_prefix, type, allow_all_models, expires_at, revoked_at, last_used_at
|
||||
FROM virtual_keys ORDER BY created_at DESC`)
|
||||
SELECT id, COALESCE(name,''), key_prefix, type, user_id, allow_all_models, expires_at, revoked_at, last_used_at
|
||||
FROM virtual_keys
|
||||
WHERE ($1::uuid IS NULL OR user_id = $1)
|
||||
ORDER BY created_at DESC`, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -100,7 +106,7 @@ func (s *Store) ListVirtualKeys(ctx context.Context) ([]VirtualKey, error) {
|
||||
var out []VirtualKey
|
||||
for rows.Next() {
|
||||
var k VirtualKey
|
||||
if err := rows.Scan(&k.ID, &k.Name, &k.KeyPrefix, &k.Type, &k.AllowAllModels,
|
||||
if err := rows.Scan(&k.ID, &k.Name, &k.KeyPrefix, &k.Type, &k.UserID, &k.AllowAllModels,
|
||||
&k.ExpiresAt, &k.RevokedAt, &k.LastUsedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user