Files
sm-golem-plugin/controllers/models_admin_controller.go
Jakub Zych 93b2b05476 fix(14): WR-04 require the AI key again when the endpoint changes
An admin who cannot read the write-only key could point base_url at
their own host, leave the key redacted and have it sent there on the
next call. An update that keeps the stored key may no longer change the
adapter or base URL; changing either needs the key re-entered.
2026-10-04 08:08:07 +02:00

106 lines
3.5 KiB
Go

package controllers
import (
"context"
"errors"
"strings"
"git.golem15.com/golem15/sm-golem-plugin/models"
"git.golem15.com/golem15/summercms/modules/cabana"
"git.golem15.com/golem15/summercms/modules/pact"
)
// PermissionAccessSettings guards the AI models screen.
const PermissionAccessSettings = "golem15.golem.access_settings"
// ModelsAdminController is the AI models list and form
// (golem15.golem.models). The API key is write-only: the form shows it
// redacted, and an update that sends it back empty or redacted keeps the
// stored key, unless the same update changes the adapter or base URL.
type ModelsAdminController struct{}
var (
_ pact.AdminController = ModelsAdminController{}
_ pact.AdminPermissioned = ModelsAdminController{}
_ pact.AdminRecordSource = ModelsAdminController{}
_ pact.FormBeforeCreate = ModelsAdminController{}
_ pact.FormBeforeUpdate = ModelsAdminController{}
)
// ID is golem15.golem.models.
func (ModelsAdminController) ID() string { return "golem15.golem.models" }
// ModelName is the model's WinterCMS class name.
func (ModelsAdminController) ModelName() string { return `Golem15\Golem\Models\AiModel` }
// ConfigDir holds config_list.yaml and config_form.yaml.
func (ModelsAdminController) ConfigDir() string { return "controllers/aimodels" }
// RequiredPermissions is golem15.golem.access_settings.
func (ModelsAdminController) RequiredPermissions() []string {
return []string{PermissionAccessSettings}
}
// NewRecord returns an empty AI model.
func (ModelsAdminController) NewRecord() any { return &models.AiModel{} }
// redacted is how lagoon.Encrypted serializes, so how the form shows the
// stored key.
const redacted = "[redacted]"
func keyMissing(m *models.AiModel) bool {
k := m.APIKey.Reveal()
return k == "" || k == redacted
}
// FormBeforeCreate requires an API key.
func (ModelsAdminController) FormBeforeCreate(_ context.Context, model any) error {
m, ok := model.(*models.AiModel)
if !ok {
return errors.New("golem: unexpected admin record")
}
if keyMissing(m) {
return &cabana.ValidationError{Details: map[string]any{"api_key": []string{"The api key field is required."}}}
}
return nil
}
// baseURLOf is a model's base URL with nil and blank alike.
func baseURLOf(m *models.AiModel) string {
if m.BaseURL == nil {
return ""
}
return strings.TrimSpace(*m.BaseURL)
}
// FormBeforeUpdate keeps the stored key when the form sent it back empty
// or redacted. A kept key never follows a changed endpoint: when the
// adapter or base URL differs from the stored one, the key must be entered
// again, so an operator who cannot read the key cannot redirect it to a
// host they control either.
func (ModelsAdminController) FormBeforeUpdate(ctx context.Context, model any) error {
m, ok := model.(*models.AiModel)
if !ok {
return errors.New("golem: unexpected admin record")
}
if !keyMissing(m) {
return nil
}
tx, ok := cabana.TxFromContext(ctx)
if !ok {
return errors.New("golem: no transaction for the model update")
}
var stored models.AiModel
if err := tx.Select("api_key", "adapter", "base_url").Where("id = ?", m.ID).Take(&stored).Error; err != nil {
return err
}
if stored.Adapter != m.Adapter || baseURLOf(&stored) != baseURLOf(m) {
return &cabana.ValidationError{Details: map[string]any{"api_key": []string{"Re-enter the API key when changing the adapter or base URL."}}}
}
m.APIKey = stored.APIKey
if keyMissing(m) {
return &cabana.ValidationError{Details: map[string]any{"api_key": []string{"The api key field is required."}}}
}
return nil
}