diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 646bb03..f9349cf 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -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) diff --git a/backend/dashboard/src/App.tsx b/backend/dashboard/src/App.tsx index 79c9c3d..2eff7ca 100644 --- a/backend/dashboard/src/App.tsx +++ b/backend/dashboard/src/App.tsx @@ -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(null) + + useEffect(() => { + if (!authed) return + api.me().then(setCurrentUser).catch(() => { + clearToken() + setAuthed(false) + }) + }, [authed]) if (!authed) { return setAuthed(true)} /> @@ -22,17 +32,19 @@ export default function App() { return ( - }> + }> } /> } /> } /> } /> - } /> - } /> } /> - } /> - } /> - } /> + {currentUser?.is_admin && <> + } /> + } /> + } /> + } /> + } + } /> diff --git a/backend/dashboard/src/components/Layout.tsx b/backend/dashboard/src/components/Layout.tsx index d104e3f..9c39bbb 100644 --- a/backend/dashboard/src/components/Layout.tsx +++ b/backend/dashboard/src/components/Layout.tsx @@ -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: }, { to: '/logs', label: 'Request Logs', icon: }, { to: '/keys', label: 'API Keys', icon: }, + { to: '/analytics',label: 'Analytics', icon: }, +] + +const ADMIN_NAV = [ { to: '/providers',label: 'Providers', icon: }, { to: '/routing', label: 'Routing Rules', icon: }, - { to: '/analytics',label: 'Analytics', icon: }, { to: '/models', label: 'Model Catalog', icon: }, ] @@ -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 (
@@ -73,12 +78,23 @@ export default function Layout() {
Main
- {NAV.map(({ to, label, icon }) => ( + {USER_NAV.map(({ to, label, icon }) => ( navStyle(isActive)}> {icon} {label} ))} + {currentUser?.is_admin && <> +
+ Admin +
+ {ADMIN_NAV.map(({ to, label, icon }) => ( + navStyle(isActive)}> + {icon} + {label} + + ))} + } {/* 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
+ }}>{(currentUser?.name || currentUser?.email || 'U')[0].toUpperCase()}
-
admin
+
+ {currentUser?.name || currentUser?.email || '…'} +
- )} - - {cfg.oidc_enabled && cfg.static_token && ( -
-
- or -
-
- )} - - {cfg.static_token && ( -
- - 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, - }} - /> - -
- )} - +
+
) } diff --git a/backend/internal/api/admin.go b/backend/internal/api/admin.go index 57a6fcb..da92892 100644 --- a/backend/internal/api/admin.go +++ b/backend/internal/api/admin.go @@ -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 diff --git a/backend/internal/api/api.go b/backend/internal/api/api.go index 42a34f7..96f8a20 100644 --- a/backend/internal/api/api.go +++ b/backend/internal/api/api.go @@ -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, }) } diff --git a/backend/internal/api/auth.go b/backend/internal/api/auth.go index 059fd1e..593c7e3 100644 --- a/backend/internal/api/auth.go +++ b/backend/internal/api/auth.go @@ -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) diff --git a/backend/internal/api/middleware.go b/backend/internal/api/middleware.go index b899c00..5fca8f8 100644 --- a/backend/internal/api/middleware.go +++ b/backend/internal/api/middleware.go @@ -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) diff --git a/backend/internal/api/oidclogin.go b/backend/internal/api/oidclogin.go index 5d2680a..663fc34 100644 --- a/backend/internal/api/oidclogin.go +++ b/backend/internal/api/oidclogin.go @@ -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. diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 440ed3b..bfc0a11 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -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 } diff --git a/backend/internal/oidcauth/oidcauth.go b/backend/internal/oidcauth/oidcauth.go index 49da8f4..9a45e4f 100644 --- a/backend/internal/oidcauth/oidcauth.go +++ b/backend/internal/oidcauth/oidcauth.go @@ -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 } diff --git a/backend/internal/store/request_logs.go b/backend/internal/store/request_logs.go index bc27e63..5d9eabd 100644 --- a/backend/internal/store/request_logs.go +++ b/backend/internal/store/request_logs.go @@ -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 diff --git a/backend/internal/store/users.go b/backend/internal/store/users.go new file mode 100644 index 0000000..009a54d --- /dev/null +++ b/backend/internal/store/users.go @@ -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 +} diff --git a/backend/internal/store/virtual_keys.go b/backend/internal/store/virtual_keys.go index 17da101..897eb82 100644 --- a/backend/internal/store/virtual_keys.go +++ b/backend/internal/store/virtual_keys.go @@ -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 }