Why: Personal project rows and credentials could override system policy and split shared business configuration by actor. Evidence: SELF receipts /tmp/project-settings-backend-full-20261014-973d52c1.json and /tmp/rdm-settings-backend-final-green-20261014-03.json preserve full commands, object snapshots and output. PostgreSQL and current-head CI remain NOT_RUN. Impact: Add scope-aware APIs, a fail-closed optional project configuration authorizer, CAS updates and explicit atomic migration. Preserve legacy rows and private preferences; protected secrets and runtime model routes obey system ownership. Refs: P1/P2/P8, docs/records/2026-10-14-rdm-settings-completion-contract.md
214 lines
6.3 KiB
Go
214 lines
6.3 KiB
Go
package server
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"net/http"
|
|
|
|
"agent-system-backend/internal/settings"
|
|
"agent-system-backend/internal/store"
|
|
"agent-system-backend/pkg/extension"
|
|
core "agent-system-core"
|
|
)
|
|
|
|
func (s *Server) SetProjectSettingsAuthorizer(fn extension.ProjectSettingsAuthorizer) {
|
|
s.projectSettingsAuthz = fn
|
|
}
|
|
|
|
func (s *Server) settingsProject(ctx context.Context, actor, id string, write bool) (core.Project, error) {
|
|
p, err := s.st.ProjectMetadata(id)
|
|
if err != nil {
|
|
return core.Project{}, err
|
|
}
|
|
if s.projectSettingsAuthz != nil {
|
|
if err := s.projectSettingsAuthz(ctx, actor, p.ID, write); err != nil {
|
|
return core.Project{}, err
|
|
}
|
|
return p, nil
|
|
}
|
|
// A domain lifecycle can revoke even the project creator. Missing settings
|
|
// capability fails closed; read/work authorization cannot grant configuration.
|
|
if s.projectLifecycle != nil {
|
|
return core.Project{}, store.ErrNotFound
|
|
}
|
|
if p.Owner != actor {
|
|
return core.Project{}, store.ErrNotFound
|
|
}
|
|
return p, nil
|
|
}
|
|
|
|
func decodeSettingsObject(r *http.Request) (map[string]any, error) {
|
|
var values map[string]any
|
|
decoder := json.NewDecoder(io.LimitReader(r.Body, 1<<20))
|
|
if err := decoder.Decode(&values); err != nil {
|
|
return nil, err
|
|
}
|
|
if values == nil {
|
|
return nil, errors.New("需要设置对象")
|
|
}
|
|
if err := decoder.Decode(new(any)); err != io.EOF {
|
|
return nil, errors.New("设置请求必须只包含一个对象")
|
|
}
|
|
return values, nil
|
|
}
|
|
|
|
func decodeStrictSettingsRequest(r *http.Request, target any) error {
|
|
decoder := json.NewDecoder(io.LimitReader(r.Body, 1<<20))
|
|
decoder.DisallowUnknownFields()
|
|
if err := decoder.Decode(target); err != nil {
|
|
return err
|
|
}
|
|
if err := decoder.Decode(new(any)); err != io.EOF {
|
|
return errors.New("请求只能包含一个对象")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Server) previewSettingsMigration(w http.ResponseWriter, r *http.Request) {
|
|
actor := s.owner(r)
|
|
project, err := s.settingsProject(r.Context(), actor, r.PathValue("id"), true)
|
|
if err != nil {
|
|
writeProjectErr(w, err)
|
|
return
|
|
}
|
|
var req struct {
|
|
Choices []settings.MigrationChoice `json:"choices"`
|
|
ExpectedRevision *int64 `json:"expectedRevision"`
|
|
}
|
|
if err := decodeStrictSettingsRequest(r, &req); err != nil || req.ExpectedRevision == nil || *req.ExpectedRevision < 0 {
|
|
http.Error(w, "需要 choices 和 expectedRevision", 400)
|
|
return
|
|
}
|
|
for _, choice := range req.Choices {
|
|
if _, err := s.st.GetUser(choice.SourceOwner); err != nil && !(s.Auth == nil && choice.SourceOwner == actor) {
|
|
writeStoreErr(w, store.ErrNotFound)
|
|
return
|
|
}
|
|
if _, err := s.settingsProject(r.Context(), choice.SourceOwner, project.ID, false); err != nil {
|
|
writeProjectErr(w, err)
|
|
return
|
|
}
|
|
}
|
|
s.set.SetExtraSchema(s.plugins.SettingsSchema())
|
|
result, err := s.set.PreviewProjectMigration(actor, project.ID, req.Choices, *req.ExpectedRevision)
|
|
if err != nil {
|
|
s.writeMigrationError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, 200, result)
|
|
}
|
|
|
|
func (s *Server) applySettingsMigration(w http.ResponseWriter, r *http.Request) {
|
|
actor := s.owner(r)
|
|
project, err := s.settingsProject(r.Context(), actor, r.PathValue("id"), true)
|
|
if err != nil {
|
|
writeProjectErr(w, err)
|
|
return
|
|
}
|
|
var req struct {
|
|
Observation string `json:"observation"`
|
|
ExpectedRevision *int64 `json:"expectedRevision"`
|
|
}
|
|
if err := decodeStrictSettingsRequest(r, &req); err != nil || req.ExpectedRevision == nil || *req.ExpectedRevision < 0 {
|
|
http.Error(w, "需要 observation 和 expectedRevision", 400)
|
|
return
|
|
}
|
|
s.set.SetExtraSchema(s.plugins.SettingsSchema())
|
|
selected, choices, err := s.set.MigrationSelection(actor, project.ID, req.Observation, *req.ExpectedRevision)
|
|
if err != nil {
|
|
s.writeMigrationError(w, err)
|
|
return
|
|
}
|
|
for _, choice := range choices {
|
|
if _, err := s.st.GetUser(choice.SourceOwner); err != nil && !(s.Auth == nil && choice.SourceOwner == actor) {
|
|
writeStoreErr(w, store.ErrNotFound)
|
|
return
|
|
}
|
|
if _, err := s.settingsProject(r.Context(), choice.SourceOwner, project.ID, false); err != nil {
|
|
writeProjectErr(w, err)
|
|
return
|
|
}
|
|
}
|
|
if err := s.validateProjectTemplate(actor, project, selected); err != nil {
|
|
writeProjectErr(w, err)
|
|
return
|
|
}
|
|
if _, err := s.settingsProject(r.Context(), actor, project.ID, true); err != nil {
|
|
writeProjectErr(w, err)
|
|
return
|
|
}
|
|
result, err := s.set.ApplyProjectMigration(actor, project.ID, req.Observation, *req.ExpectedRevision, selected)
|
|
if err != nil {
|
|
s.writeMigrationError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, 200, result)
|
|
}
|
|
|
|
func (s *Server) writeMigrationError(w http.ResponseWriter, err error) {
|
|
if errors.Is(err, settings.ErrSettingsConflict) {
|
|
http.Error(w, err.Error(), 409)
|
|
return
|
|
}
|
|
var invalid *settings.ErrInvalidSetting
|
|
if errors.As(err, &invalid) {
|
|
http.Error(w, err.Error(), 400)
|
|
return
|
|
}
|
|
writeProjectErr(w, err)
|
|
}
|
|
|
|
func (s *Server) putSettingsPersonalProject(w http.ResponseWriter, r *http.Request) {
|
|
owner := s.owner(r)
|
|
p, err := s.settingsProject(r.Context(), owner, r.PathValue("id"), false)
|
|
if err != nil {
|
|
writeProjectErr(w, err)
|
|
return
|
|
}
|
|
values, err := decodeSettingsObject(r)
|
|
if err != nil {
|
|
http.Error(w, err.Error(), 400)
|
|
return
|
|
}
|
|
if err := s.set.ValidateWrite(values, settings.PersonalProjectScope); err != nil {
|
|
http.Error(w, err.Error(), 400)
|
|
return
|
|
}
|
|
s.writeSettingsMerge(w, func() error { return s.set.MergePersonalProject(owner, p.ID, values) })
|
|
}
|
|
|
|
func (s *Server) validateProjectTemplate(actor string, p core.Project, values map[string]any) error {
|
|
projected := s.set.ProjectValues(values, settings.ProjectSettingScope)
|
|
id := core.EffectiveSettings(projected).GetString("deliverables.templateArtifactId", "")
|
|
if id == "" {
|
|
return nil
|
|
}
|
|
a, err := s.st.GetArtifactByID(id)
|
|
if err != nil || a.ProjectID != p.ID {
|
|
return store.ErrNotFound
|
|
}
|
|
if s.ProjectAuthzV2 != nil {
|
|
if !s.authorizeSharedResource(actor, "model-context", "artifact", a.ID, a.Owner, a.ProjectID) {
|
|
return store.ErrNotFound
|
|
}
|
|
} else if a.Owner != actor {
|
|
return store.ErrNotFound
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Server) getSettingsMigration(w http.ResponseWriter, r *http.Request) {
|
|
if _, ok := s.requireSystemAdmin(w, r); !ok {
|
|
return
|
|
}
|
|
if r.URL.Query().Get("dryRun") != "true" {
|
|
http.Error(w, "迁移清单仅支持 dryRun=true;不执行自动迁移", 400)
|
|
return
|
|
}
|
|
s.set.SetExtraSchema(s.plugins.SettingsSchema())
|
|
view, err := s.set.MigrationInventory()
|
|
writeJSONOr(w, view, err)
|
|
}
|