Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions config/config.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -536,3 +536,10 @@ providers:
# input_per_mtok: 0
# output_per_mtok: 0
# - Gemma4-31B

# Extensions are absent by default. Add a named section only when a custom
# distribution requires it. Core preserves these values without depending on
# the extension's schema; the owning extension strictly validates its section.
# extensions:
# example:
# enabled: true
29 changes: 29 additions & 0 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,11 @@ type Config struct {
Session SessionConfig `yaml:"session"`
MCP MCPConfig `yaml:"mcp"`

// Extensions holds configuration owned by custom distributions. Core keeps
// the values opaque; an extension decodes its named section with
// LoadResult.DecodeExtension.
Extensions map[string]yaml.Node `yaml:"extensions,omitempty"`

// VirtualModels declares redirects, load balancers, and access policies as
// infrastructure-as-code. They override admin-store rows of the same source.
VirtualModels []VirtualModelConfig `yaml:"virtual_models"`
Expand All @@ -52,6 +57,30 @@ type LoadResult struct {
RawProviders map[string]RawProviderConfig
}

// DecodeExtension strictly decodes one named extensions: section into target.
// It returns false when the section is absent. Core deliberately does not know
// any extension's schema, while each extension still gets unknown-key safety.
func (r *LoadResult) DecodeExtension(name string, target any) (bool, error) {
if r == nil || r.Config == nil || target == nil {
return false, nil
}
name = strings.TrimSpace(name)
node, ok := r.Config.Extensions[name]
if !ok {
return false, nil
}
data, err := yaml.Marshal(&node)
if err != nil {
return false, fmt.Errorf("encode extensions.%s: %w", name, err)
}
decoder := yaml.NewDecoder(strings.NewReader(string(data)))
decoder.KnownFields(true)
if err := decoder.Decode(target); err != nil {
return false, fmt.Errorf("decode extensions.%s: %w", name, err)
}
return true, nil
}

// buildDefaultConfig returns the single source of truth for all configuration defaults.
func buildDefaultConfig() *Config {
return &Config{
Expand Down
69 changes: 69 additions & 0 deletions config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,75 @@ func TestBuildDefaultConfig(t *testing.T) {
}
}

func TestDecodeExtensionStrictlyDecodesOpaqueConfig(t *testing.T) {
var node yaml.Node
if err := yaml.Unmarshal([]byte("enabled: true\npkce_enabled: false\n"), &node); err != nil {
t.Fatal(err)
}
type ssoConfig struct {
Enabled bool `yaml:"enabled"`
PKCEEnabled bool `yaml:"pkce_enabled"`
}
result := &LoadResult{Config: &Config{Extensions: map[string]yaml.Node{"sso": node}}}
var got ssoConfig
found, err := result.DecodeExtension("sso", &got)
if err != nil {
t.Fatal(err)
}
if !found || !got.Enabled || got.PKCEEnabled {
t.Fatalf("found=%v config=%+v", found, got)
}

var unknown yaml.Node
if err := yaml.Unmarshal([]byte("unknown: true\n"), &unknown); err != nil {
t.Fatal(err)
}
result.Config.Extensions["sso"] = unknown
if _, err := result.DecodeExtension("sso", &got); err == nil {
t.Fatal("expected extension-owned unknown key to be rejected")
}
}

func TestDecodeExtensionHandlesAbsentConfiguration(t *testing.T) {
var nilResult *LoadResult
if found, err := nilResult.DecodeExtension("sso", &struct{}{}); err != nil || found {
t.Fatalf("nil result: found=%v err=%v", found, err)
}
result := &LoadResult{Config: &Config{}}
if found, err := result.DecodeExtension("sso", &struct{}{}); err != nil || found {
t.Fatalf("missing extension: found=%v err=%v", found, err)
}
if found, err := result.DecodeExtension("sso", nil); err != nil || found {
t.Fatalf("nil target: found=%v err=%v", found, err)
}
}

func TestLoadPreservesOpaqueExtensionConfiguration(t *testing.T) {
clearAllConfigEnvVars(t)
withTempDir(t, func(dir string) {
contents := []byte("extensions:\n sso:\n enabled: true\n provider_specific_option: value\n")
if err := os.WriteFile(filepath.Join(dir, "config.yaml"), contents, 0o644); err != nil {
t.Fatal(err)
}

result, err := Load()
if err != nil {
t.Fatalf("Load() rejected extension-owned keys: %v", err)
}
var decoded struct {
Enabled bool `yaml:"enabled"`
ProviderSpecificOption string `yaml:"provider_specific_option"`
}
found, err := result.DecodeExtension("sso", &decoded)
if err != nil {
t.Fatal(err)
}
if !found || !decoded.Enabled || decoded.ProviderSpecificOption != "value" {
t.Fatalf("found=%v config=%+v", found, decoded)
}
})
}

func TestLoadBudgetEnvUserPath(t *testing.T) {
clearAllConfigEnvVars(t)

Expand Down
102 changes: 102 additions & 0 deletions ext/auth.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
package ext

import (
"context"
"net/http"
"slices"
"time"
)

type authenticationContextKey struct{}
type clearedAuthentication struct{}

// Provider-neutral response headers let the bundled dashboard discover an
// extension-managed browser authentication flow without knowing whether the
// provider uses OIDC, SAML, or another protocol. Values are app-local paths.
const (
AuthenticationLoginHeader = "X-GoModel-Auth-Login"
AuthenticationLogoutHeader = "X-GoModel-Auth-Logout"
AuthenticationUserHeader = "X-GoModel-Auth-User"
)

// Authentication describes an identity established by an extension.
// PrincipalID must be a stable, non-secret identifier within the
// authenticator's namespace. UserPath is the existing GoModel authorization
// and accounting subject; it is deliberately separate because a login identity
// and a policy hierarchy are not the same thing.
type Authentication struct {
PrincipalID string
UserPath string
Labels []string
DashboardAccess bool
// Method is a short, stable audit identifier such as "oidc" or "saml".
// Core normalizes safe identifiers and records "extension" when it is empty
// or invalid.
Method string
}

// RequestAuthenticator authenticates requests using a mechanism other than
// the core bearer-token authenticators (for example an OIDC browser session).
// A nil result with a nil error means the mechanism does not apply to the
// request. Implementations must be safe for concurrent use.
type RequestAuthenticator interface {
Name() string
AuthenticateRequest(ctx context.Context, request *http.Request) (*Authentication, error)
}

// AuthenticationEvent is a security-audit record emitted by an extension
// authentication flow. Reason must be a short, stable machine identifier; it
// must not contain provider responses, tokens, claims, or other secrets.
type AuthenticationEvent struct {
Timestamp time.Time
Type string
Outcome string
Method string
PrincipalID string
UserPath string
RequestID string
ClientIP string
HTTPMethod string
Path string
UserAgent string
Reason string
}

// AuthenticationEventRecorder persists authentication lifecycle events in
// Core's audit trail. Implementations must be safe for concurrent use and
// must not block the authentication flow on durable storage I/O.
type AuthenticationEventRecorder interface {
RecordAuthenticationEvent(AuthenticationEvent)
}

// AuthenticationEventRecorderAware is optionally implemented by a request
// authenticator that emits login, rejection, and logout lifecycle events.
// Core installs the recorder after its audit subsystem has initialized.
type AuthenticationEventRecorderAware interface {
SetAuthenticationEventRecorder(AuthenticationEventRecorder)
}

// WithAuthentication attaches an extension-established identity to a request
// context. Core calls this after accepting an authenticator result.
func WithAuthentication(ctx context.Context, authentication Authentication) context.Context {
authentication.Labels = slices.Clone(authentication.Labels)
return context.WithValue(ctx, authenticationContextKey{}, authentication)
}

// WithoutAuthentication returns a context that hides any extension identity
// inherited from an outer middleware. Core uses it at the explicit-credential
// boundary so a bearer token cannot retain an ambient cookie principal.
func WithoutAuthentication(ctx context.Context) context.Context {
return context.WithValue(ctx, authenticationContextKey{}, clearedAuthentication{})
}

// AuthenticationFromContext returns the extension-established identity, if
// the request was authenticated by an extension.
func AuthenticationFromContext(ctx context.Context) (Authentication, bool) {
if ctx == nil {
return Authentication{}, false
}
authentication, ok := ctx.Value(authenticationContextKey{}).(Authentication)
authentication.Labels = slices.Clone(authentication.Labels)
return authentication, ok
}
53 changes: 53 additions & 0 deletions ext/auth_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package ext

import (
"context"
"testing"
)

func TestAuthenticationContextRoundTripClonesLabels(t *testing.T) {
authentication := Authentication{
PrincipalID: "oidc:principal-1",
UserPath: "/users/one",
Labels: []string{"team-a"},
Method: "oidc",
}
ctx := WithAuthentication(t.Context(), authentication)
authentication.Labels[0] = "mutated-source"

got, ok := AuthenticationFromContext(ctx)
if !ok {
t.Fatal("authentication missing from context")
}
if got.PrincipalID != "oidc:principal-1" || got.Labels[0] != "team-a" {
t.Fatalf("authentication = %+v", got)
}
got.Labels[0] = "mutated-result"

again, ok := AuthenticationFromContext(ctx)
if !ok || again.Labels[0] != "team-a" {
t.Fatalf("stored authentication was mutated: %+v", again)
}
}

func TestWithoutAuthenticationHidesInheritedIdentity(t *testing.T) {
ctx := WithAuthentication(t.Context(), Authentication{
PrincipalID: "oidc:ambient",
UserPath: "/users/ambient",
Labels: []string{"sso"},
})
ctx = WithoutAuthentication(ctx)

if authentication, ok := AuthenticationFromContext(ctx); ok {
t.Fatalf("AuthenticationFromContext() = %+v, true; want no identity", authentication)
}
}

func TestAuthenticationFromContextHandlesMissingContext(t *testing.T) {
if _, ok := AuthenticationFromContext(nil); ok { //nolint:staticcheck // Exercise the helper's defensive nil-context branch.
t.Fatal("nil context returned an authentication")
}
if _, ok := AuthenticationFromContext(context.Background()); ok {
t.Fatal("empty context returned an authentication")
}
}
35 changes: 28 additions & 7 deletions ext/registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,22 @@ import (
// Register everything before the server is constructed (before run.Run or
// app.New); core snapshots each registration list during initialization.
type Registry struct {
mu sync.Mutex
rewriters []RequestRewriter
middleware []echo.MiddlewareFunc
routes []func(*echo.Echo)
publicPaths []string
routeSelector RouteSelector
settings []RuntimeSetting
mu sync.Mutex
rewriters []RequestRewriter
middleware []echo.MiddlewareFunc
routes []func(*echo.Echo)
publicPaths []string
routeSelector RouteSelector
settings []RuntimeSetting
authenticators []RequestAuthenticator
}

// RegisterAuthenticator adds a request authentication mechanism. Core bearer
// tokens keep precedence when a request explicitly supplies one.
func (r *Registry) RegisterAuthenticator(authenticator RequestAuthenticator) {
r.mu.Lock()
defer r.mu.Unlock()
r.authenticators = append(r.authenticators, authenticator)
}

// RegisterSetting adds a deployment-wide setting exposed through the generic
Expand Down Expand Up @@ -112,6 +121,13 @@ func (r *Registry) Settings() []RuntimeSetting {
return slices.Clone(r.settings)
}

// Authenticators returns a defensive copy of registered request authenticators.
func (r *Registry) Authenticators() []RequestAuthenticator {
r.mu.Lock()
defer r.mu.Unlock()
return slices.Clone(r.authenticators)
}

// Default is the process-wide registry used by package-level helpers and, by
// default, by run.Run.
var Default = &Registry{}
Expand All @@ -133,3 +149,8 @@ func RegisterRouteSelector(sel RouteSelector) { Default.RegisterRouteSelector(se

// RegisterSetting registers a runtime setting on the Default registry.
func RegisterSetting(setting RuntimeSetting) { Default.RegisterSetting(setting) }

// RegisterAuthenticator registers a request authenticator on the Default registry.
func RegisterAuthenticator(authenticator RequestAuthenticator) {
Default.RegisterAuthenticator(authenticator)
}
20 changes: 20 additions & 0 deletions ext/registry_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package ext

import (
"context"
"net/http"
"sync"
"testing"

Expand All @@ -12,6 +13,14 @@ import (

type namedRewriter struct{ name string }

type namedAuthenticator struct{ name string }

func (a *namedAuthenticator) Name() string { return a.name }

func (a *namedAuthenticator) AuthenticateRequest(context.Context, *http.Request) (*Authentication, error) {
return nil, nil
}

type testRuntimeSetting struct{ value string }

func (s *testRuntimeSetting) Descriptor() SettingDescriptor {
Expand Down Expand Up @@ -69,6 +78,17 @@ func TestRegistryCollectsMiddlewareAndRoutes(t *testing.T) {
assert.Len(t, reg.Routes(), 1)
}

func TestRegistryCollectsRequestAuthenticators(t *testing.T) {
reg := &Registry{}
reg.RegisterAuthenticator(&namedAuthenticator{name: "oidc"})

snapshot := reg.Authenticators()
require.Len(t, snapshot, 1)
assert.Equal(t, "oidc", snapshot[0].Name())
reg.RegisterAuthenticator(&namedAuthenticator{name: "other"})
assert.Len(t, snapshot, 1, "earlier snapshot must not grow")
}

func TestRegistryCollectsRuntimeSettings(t *testing.T) {
reg := &Registry{}
reg.RegisterSetting(&testRuntimeSetting{value: "high"})
Expand Down
68 changes: 0 additions & 68 deletions internal/admin/dashboard/static/dist/assets/index-D7DzAhDp.js

This file was deleted.

Large diffs are not rendered by default.

68 changes: 68 additions & 0 deletions internal/admin/dashboard/static/dist/assets/index-DbvfJbHk.js

Large diffs are not rendered by default.

This file was deleted.

4 changes: 2 additions & 2 deletions internal/admin/dashboard/static/dist/index.html

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading