Admin-gated Settings: runtime model selection, enforce is_admin (#79)
Moves the agent model out of env-only config into an admin-editable Settings
section, and enforces is_admin for the first time — it has been in the schema
since migration 0001, plumbed to the client, and checked nowhere.
Backend:
- Migration 0010: instance_settings, a single-row (CHECK id=1) table — pansy's
first instance-level state. Holds agent_model ('' = inherit env) and
agent_enabled (NULL = inherit env), version-guarded like every mutable row.
SECRETS STAY IN ENV: OLLAMA_CLOUD_API_KEY is never stored here.
- requireAdmin at the service seam (authoritative) plus a cheap middleware
early-403. Non-admin gets 403, not 404 — settings existence isn't masked.
- EffectiveAgent resolves DB-over-env (model, enabled); key always from env.
- The live Runner is hot-swapped, not built once. agentHolder holds it behind
an atomic.Pointer; the chat routes are now registered UNCONDITIONALLY and
nil-check agent.get(), so a settings change turns the assistant on/off/onto a
new model with no restart and no race against in-flight readers. /capabilities
reads the pointer, so it reports what's live, not what booted.
- internal/agentmodel is a new leaf package holding the one place that knows how
to turn a spec into a model. Both agent (to run) and service (to validate a
spec before storing it) import it; it can't live in agent, which imports
service. Settings PATCH validates the spec via Parse, so a typo is a 400 now
rather than a broken assistant on the next turn.
Frontend:
- /settings route (admin guard), a Settings page (model field, tri-state
enabled, live status), nav link shown only to admins.
- useCapabilities drops staleTime:Infinity — the assistant can now change under
a running page — and the settings save invalidates it.
Contract change: chat routes always exist, so "assistant off" is a runtime 503
+ capabilities:false, not a missing route. Updated the test that asserted the
old shape.
Verified live against the built binary: disable flips capabilities to false and
logs it; re-enable with a new model swaps it back; a bad spec is rejected 400;
the setting persists across a restart. Swap is race-clean under `go test -race`.
Docs: README (precedence + key-stays-in-env), DESIGN (decision + routes),
CLAUDE (don't re-add conditional route registration; key never in the DB).
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
This commit is contained in:
@@ -0,0 +1,110 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"gitea.stevedudenhoeffer.com/steve/pansy/internal/agentmodel"
|
||||
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
|
||||
)
|
||||
|
||||
// Instance settings (#79): admin-editable, instance-wide configuration. The
|
||||
// admin gate lives HERE, in the seam, not in the handler — the same rule every
|
||||
// other permission follows. The API's requireAdmin middleware is a cheap early
|
||||
// 403, not the authority.
|
||||
|
||||
// requireAdmin returns nil iff the actor is an admin, else ErrForbidden.
|
||||
//
|
||||
// ErrForbidden, not ErrNotFound: settings are not a resource whose existence is
|
||||
// masked. A logged-in non-admin knows the instance has settings; they simply may
|
||||
// not touch them. (Contrast objects/lots, where no-access masks existence.)
|
||||
func (s *Service) requireAdmin(ctx context.Context, actorID int64) error {
|
||||
u, err := s.store.GetUserByID(ctx, actorID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !u.IsAdmin {
|
||||
return domain.ErrForbidden
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetInstanceSettings returns the instance settings for an admin.
|
||||
func (s *Service) GetInstanceSettings(ctx context.Context, actorID int64) (*domain.InstanceSettings, error) {
|
||||
if err := s.requireAdmin(ctx, actorID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.store.GetInstanceSettings(ctx)
|
||||
}
|
||||
|
||||
// InstanceSettingsPatch is a full replacement of the editable fields plus the
|
||||
// current version. Both agent fields carry their "inherit" sentinel: an empty
|
||||
// AgentModel means fall back to env, a nil AgentEnabled means inherit.
|
||||
type InstanceSettingsPatch struct {
|
||||
AgentModel string
|
||||
AgentEnabled *bool
|
||||
Version int64
|
||||
}
|
||||
|
||||
// UpdateInstanceSettings applies an admin's change, version-guarded. It returns
|
||||
// (current row, ErrVersionConflict) on a stale version, like every mutable
|
||||
// resource. The model spec is validated before it is stored, so a typo is a 400
|
||||
// now rather than a broken assistant on the next turn.
|
||||
//
|
||||
// It does NOT rebuild the running agent — that is the API layer's job, because
|
||||
// the live Runner lives there. The caller rebuilds on success.
|
||||
func (s *Service) UpdateInstanceSettings(ctx context.Context, actorID int64, patch InstanceSettingsPatch) (*domain.InstanceSettings, error) {
|
||||
if err := s.requireAdmin(ctx, actorID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
model := strings.TrimSpace(patch.AgentModel)
|
||||
// Validate a non-empty spec up front. An empty one is the "inherit env"
|
||||
// sentinel and needs no check — the env value was validated at boot.
|
||||
if model != "" {
|
||||
if err := agentmodel.Validate(s.cfg.Agent.OllamaCloudAPIKey, model); err != nil {
|
||||
return nil, domain.ErrInvalidInput
|
||||
}
|
||||
}
|
||||
return s.store.UpdateInstanceSettings(ctx, &domain.InstanceSettings{
|
||||
AgentModel: model,
|
||||
AgentEnabled: patch.AgentEnabled,
|
||||
Version: patch.Version,
|
||||
})
|
||||
}
|
||||
|
||||
// EffectiveAgent resolves the agent configuration actually in force: DB settings
|
||||
// override the environment, and the API key always comes from the environment.
|
||||
//
|
||||
// This is internal plumbing (the API layer calls it to build the Runner), NOT an
|
||||
// admin-gated operation — resolving what's configured is not the same as editing
|
||||
// it, and the agent bootstrap must work before any user is even authenticated.
|
||||
type EffectiveAgent struct {
|
||||
Model string
|
||||
Enabled bool
|
||||
APIKey string
|
||||
}
|
||||
|
||||
// Ready mirrors config.AgentConfig.Ready: enabled, with a key and a model.
|
||||
func (e EffectiveAgent) Ready() bool {
|
||||
return e.Enabled && e.APIKey != "" && e.Model != ""
|
||||
}
|
||||
|
||||
// EffectiveAgent reads the settings row and layers it over the environment.
|
||||
func (s *Service) EffectiveAgent(ctx context.Context) (EffectiveAgent, error) {
|
||||
st, err := s.store.GetInstanceSettings(ctx)
|
||||
if err != nil {
|
||||
return EffectiveAgent{}, err
|
||||
}
|
||||
eff := EffectiveAgent{
|
||||
Model: s.cfg.Agent.Model,
|
||||
Enabled: s.cfg.Agent.Enabled,
|
||||
APIKey: s.cfg.Agent.OllamaCloudAPIKey,
|
||||
}
|
||||
if st.AgentModel != "" {
|
||||
eff.Model = st.AgentModel
|
||||
}
|
||||
if st.AgentEnabled != nil {
|
||||
eff.Enabled = *st.AgentEnabled
|
||||
}
|
||||
return eff, nil
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"gitea.stevedudenhoeffer.com/steve/pansy/internal/config"
|
||||
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
|
||||
)
|
||||
|
||||
// settingsTestService builds a service whose env config carries the given agent
|
||||
// model/enabled/key, so EffectiveAgent's env fallback can be exercised.
|
||||
func settingsTestService(t *testing.T, envModel string, envEnabled bool, key string) (*Service, int64) {
|
||||
t.Helper()
|
||||
cfg := openConfig()
|
||||
cfg.Agent = config.AgentConfig{Model: envModel, Enabled: envEnabled, OllamaCloudAPIKey: key}
|
||||
s := newTestService(t, cfg)
|
||||
admin := seedUser(t, s, "[email protected]") // first user is admin
|
||||
return s, admin
|
||||
}
|
||||
|
||||
// TestRequireAdmin: the first user is admin; a second is not and gets
|
||||
// ErrForbidden (not ErrNotFound — settings existence isn't masked).
|
||||
func TestRequireAdmin(t *testing.T) {
|
||||
s, admin := settingsTestService(t, "ollama-cloud/x", true, "k")
|
||||
member := seedUser(t, s, "[email protected]")
|
||||
|
||||
if err := s.requireAdmin(context.Background(), admin); err != nil {
|
||||
t.Errorf("admin rejected: %v", err)
|
||||
}
|
||||
if err := s.requireAdmin(context.Background(), member); !errors.Is(err, domain.ErrForbidden) {
|
||||
t.Errorf("member requireAdmin = %v, want ErrForbidden", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEffectiveAgentLayering: DB settings override env; the API key always comes
|
||||
// from env; the "inherit" sentinels fall back.
|
||||
func TestEffectiveAgentLayering(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s, admin := settingsTestService(t, "ollama-cloud/env-model", true, "envkey")
|
||||
|
||||
// Untouched: everything inherits env.
|
||||
eff, err := s.EffectiveAgent(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("effective: %v", err)
|
||||
}
|
||||
if eff.Model != "ollama-cloud/env-model" || !eff.Enabled || eff.APIKey != "envkey" {
|
||||
t.Errorf("inherited effective = %+v, want the env values", eff)
|
||||
}
|
||||
|
||||
// Override the model only; enabled still inherits env (true).
|
||||
cur, _ := s.GetInstanceSettings(ctx, admin)
|
||||
if _, err := s.UpdateInstanceSettings(ctx, admin, InstanceSettingsPatch{
|
||||
AgentModel: "ollama-cloud/glm-5.2:cloud", Version: cur.Version,
|
||||
}); err != nil {
|
||||
t.Fatalf("update model: %v", err)
|
||||
}
|
||||
eff, _ = s.EffectiveAgent(ctx)
|
||||
if eff.Model != "ollama-cloud/glm-5.2:cloud" {
|
||||
t.Errorf("model = %q, want the DB override", eff.Model)
|
||||
}
|
||||
if !eff.Enabled {
|
||||
t.Error("enabled should still inherit env (true) when unset")
|
||||
}
|
||||
|
||||
// Now override enabled to false explicitly.
|
||||
cur, _ = s.GetInstanceSettings(ctx, admin)
|
||||
no := false
|
||||
if _, err := s.UpdateInstanceSettings(ctx, admin, InstanceSettingsPatch{
|
||||
AgentModel: "ollama-cloud/glm-5.2:cloud", AgentEnabled: &no, Version: cur.Version,
|
||||
}); err != nil {
|
||||
t.Fatalf("update enabled: %v", err)
|
||||
}
|
||||
eff, _ = s.EffectiveAgent(ctx)
|
||||
if eff.Enabled {
|
||||
t.Error("enabled should be the explicit false override now")
|
||||
}
|
||||
if eff.Ready() {
|
||||
t.Error("Ready() should be false when disabled")
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpdateInstanceSettingsRejectsBadModel: a spec that won't resolve is
|
||||
// ErrInvalidInput, before it is stored.
|
||||
func TestUpdateInstanceSettingsRejectsBadModel(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s, admin := settingsTestService(t, "ollama-cloud/x", true, "k")
|
||||
cur, _ := s.GetInstanceSettings(ctx, admin)
|
||||
|
||||
if _, err := s.UpdateInstanceSettings(ctx, admin, InstanceSettingsPatch{
|
||||
AgentModel: "nonesuch/model", Version: cur.Version,
|
||||
}); !errors.Is(err, domain.ErrInvalidInput) {
|
||||
t.Errorf("bad model = %v, want ErrInvalidInput", err)
|
||||
}
|
||||
|
||||
// The rejected write didn't touch the row.
|
||||
after, _ := s.GetInstanceSettings(ctx, admin)
|
||||
if after.Version != cur.Version || after.AgentModel != "" {
|
||||
t.Errorf("a rejected update changed the row: %+v", after)
|
||||
}
|
||||
}
|
||||
|
||||
// TestInstanceSettingsAdminGate: the read/write operations are admin-gated at the
|
||||
// service seam, not just in the handler.
|
||||
func TestInstanceSettingsAdminGate(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s, _ := settingsTestService(t, "ollama-cloud/x", true, "k")
|
||||
member := seedUser(t, s, "[email protected]")
|
||||
|
||||
if _, err := s.GetInstanceSettings(ctx, member); !errors.Is(err, domain.ErrForbidden) {
|
||||
t.Errorf("member GetInstanceSettings = %v, want ErrForbidden", err)
|
||||
}
|
||||
if _, err := s.UpdateInstanceSettings(ctx, member, InstanceSettingsPatch{Version: 1}); !errors.Is(err, domain.ErrForbidden) {
|
||||
t.Errorf("member UpdateInstanceSettings = %v, want ErrForbidden", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user