Why:
Legacy project metadata can contain credentials outside the settings scope table.
Sharing raw projection settings exposed them, and generic project PATCH bypassed
the system-managed path guard already enforced by project settings endpoints.
Evidence:
SELF member Host regression failed before the fix. The fixed 4f72aaf server
baseline overlay reproduced HTTP metadata leakage and managed PATCH returning 200.
SELF shared Host-to-HTTP, immutable mask, dotted/schema/fallback, public mutation
and atomic managed-write regressions passed; uncached full suite, isolated PG
Store suite and static build passed. Independent delta replay remains a gate.
Impact:
Registered lifecycle metadata returns detached credential-masked views while
preserving service/configuration keys and persisted data. Generic project PATCH
shares the existing managed validator before rename or settings mutation; allowed
metadata key shapes and raw owner-only management lookups remain available.
Refs:
Dev project-member-access plan r1 Task 1 and R5; approved hard secret boundary;
2026-10-13 shared project metadata review follow-up
83 lines
2.5 KiB
Go
83 lines
2.5 KiB
Go
package server
|
|
|
|
import (
|
|
"agent-system-backend/internal/store"
|
|
"agent-system-backend/pkg/extension"
|
|
core "agent-system-core"
|
|
"context"
|
|
"errors"
|
|
"net/http"
|
|
)
|
|
|
|
// SetProjectLifecycle is wired only for a registered in-process provider. The
|
|
// optional host factory constructs a capability scoped to this request's actor.
|
|
func (s *Server) SetProjectLifecycle(p extension.ProjectLifecycle, host func(string) extension.Host) {
|
|
s.projectLifecycle = &p
|
|
s.projectHost = host
|
|
}
|
|
func (s *Server) lifecycleHost(owner string) extension.Host {
|
|
if s.projectHost == nil {
|
|
return nil
|
|
}
|
|
return s.projectHost(owner)
|
|
}
|
|
func (s *Server) projectWork(ctx context.Context, owner, projectID string) error {
|
|
if s.projectLifecycle == nil {
|
|
return nil
|
|
}
|
|
if s.projectLifecycle.Work == nil {
|
|
return store.ErrNotFound
|
|
}
|
|
ok, err := s.projectLifecycle.Work(ctx, owner, core.ID(projectID))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !ok {
|
|
return store.ErrNotFound
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// createPrivateConversation is shared by the API and /new. Only live work
|
|
// authorization grants member creation; the resulting conversation stays owned.
|
|
func (s *Server) createPrivateConversation(ctx context.Context, owner, projectID, title, profile string) (core.Conversation, error) {
|
|
if s.projectLifecycle != nil {
|
|
if err := s.projectWork(ctx, owner, projectID); err != nil {
|
|
return core.Conversation{}, err
|
|
}
|
|
return s.st.CreateConversationInProject(owner, projectID, title, profile)
|
|
}
|
|
c, err := s.st.CreateConversation(owner, projectID, title, profile)
|
|
if errors.Is(err, store.ErrNotFound) && s.authorizeSharedResource(owner, "read", "project", core.ID(projectID), "", core.ID(projectID)) {
|
|
return s.st.CreateConversationInProject(owner, projectID, title, profile)
|
|
}
|
|
return c, err
|
|
}
|
|
func writeProjectErr(w http.ResponseWriter, err error) {
|
|
var coded interface{ StatusCode() int }
|
|
if errors.As(err, &coded) {
|
|
switch code := coded.StatusCode(); code {
|
|
case 400, 403, 404, 409, 503:
|
|
http.Error(w, err.Error(), code)
|
|
return
|
|
}
|
|
}
|
|
writeStoreErr(w, err)
|
|
}
|
|
func writeProjectJSON(w http.ResponseWriter, p any, err error) {
|
|
if err != nil {
|
|
writeProjectErr(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, p)
|
|
}
|
|
|
|
// publicProjectMetadata also masks provider-returned metadata, since providers
|
|
// may obtain raw owner-management rows before producing a shared public view.
|
|
func (s *Server) publicProjectMetadata(p core.Project) core.Project {
|
|
if s.projectLifecycle != nil {
|
|
p.Settings = s.set.MaskedProjectMetadata(p.Settings)
|
|
}
|
|
return p
|
|
}
|