Files
Agent-System-Backend/internal/server/project_settings.go
T
weichen f965c5877f Enforce settings ownership and versioned shared configuration
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
2026-10-14 02:28:41 +00:00

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)
}