Files
Agent-System-Backend/internal/app/project_lifecycle.go
T
weichen c4435d1415 Protect shared project metadata credentials and managed settings
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
2026-10-13 09:46:20 +00:00

125 lines
3.8 KiB
Go

package app
import (
"agent-system-backend/internal/projectscope"
"agent-system-backend/internal/server"
"agent-system-backend/internal/store"
"agent-system-backend/pkg/extension"
core "agent-system-core"
"context"
"errors"
"sync"
)
type projectLifecycleSlot struct {
mu sync.Mutex
holder string
provider *extension.ProjectLifecycle
}
func (r compiledRegistry) RegisterProjectLifecycle(extension.ProjectLifecycle) error {
return errors.New("extension: declarative plugins cannot register project lifecycle")
}
func (r pluginRegistry) RegisterProjectLifecycle(p extension.ProjectLifecycle) error {
if r.caps.projects == nil || r.pluginID == "" {
return errors.New("extension: project lifecycle slot unavailable")
}
if p.List == nil || p.Create == nil || p.Rename == nil || p.Archive == nil || p.Work == nil {
return errors.New("extension: project lifecycle requires all callbacks")
}
slot := r.caps.projects
slot.mu.Lock()
defer slot.mu.Unlock()
if slot.provider != nil {
return errors.New("extension: project lifecycle already registered")
}
slot.holder, slot.provider = r.pluginID, &p
return nil
}
func (r recordRegistry) RegisterProjectLifecycle(p extension.ProjectLifecycle) error {
reg, ok := r.Registry.(extension.ProjectLifecycleRegistry)
if !ok {
return errors.New("extension: project lifecycle registration unavailable")
}
return reg.RegisterProjectLifecycle(p)
}
func (slot *projectLifecycleSlot) wire(srv *server.Server, caps hostcaps) {
slot.mu.Lock()
defer slot.mu.Unlock()
if slot.provider == nil {
return
}
holder := slot.holder
srv.SetProjectLifecycle(*slot.provider, func(owner string) extension.Host {
return inprocHost{caps: caps, owner: owner, actor: owner, pluginID: holder}
})
}
func (slot *projectLifecycleSlot) active() bool {
slot.mu.Lock()
defer slot.mu.Unlock()
return slot.provider != nil
}
func (slot *projectLifecycleSlot) work() func(context.Context, string, core.ID) (bool, error) {
slot.mu.Lock()
defer slot.mu.Unlock()
if slot.provider == nil {
return nil
}
return slot.provider.Work
}
func (slot *projectLifecycleSlot) resolve(ctx context.Context, caps hostcaps, owner string, id core.ID) (core.ID, error) {
slot.mu.Lock()
if slot.provider == nil {
slot.mu.Unlock()
return id, nil
}
p, holder := *slot.provider, slot.holder
slot.mu.Unlock()
return projectscope.Resolve(ctx, inprocHost{caps: caps, owner: owner, actor: owner, pluginID: holder}, p, id)
}
var _ extension.ProjectHost = inprocHost{}
func (h inprocHost) OwnedProjects() ([]core.Project, error) {
ps, err := h.caps.st.ListProjects(h.owner)
for i := range ps {
ps[i].Owner = h.owner
}
return ps, err
}
func (h inprocHost) OwnedProject(id string) (core.Project, error) {
return h.caps.st.GetProject(h.owner, id)
}
func (h inprocHost) ProjectMetadata(id string) (core.Project, error) {
p, err := h.caps.st.ProjectMetadata(id)
if err != nil {
return core.Project{}, err
}
if h.caps.projects != nil && h.caps.projects.active() {
p.Settings = h.caps.set.MaskedProjectMetadata(p.Settings)
}
if p.Owner == h.owner {
return p, nil
}
if h.caps.authz == nil {
return core.Project{}, store.ErrNotFound
}
ok, err := h.caps.authz.AuthorizeResource(extension.ResourceAuthzQuery{Actor: h.owner, Action: "read", ResourceType: "project", ResourceID: core.ID(id), Owner: p.Owner, ProjectID: core.ID(id)})
if err != nil || !ok {
return core.Project{}, store.ErrNotFound
}
return p, nil
}
func (h inprocHost) EnsureProject(id, name string) (core.Project, error) {
return h.caps.st.EnsureProject(h.owner, id, name)
}
func (h inprocHost) RenameOwnedProject(id, name string) (core.Project, error) {
return h.caps.st.RenameOwnedProject(h.owner, id, name)
}
func (h inprocHost) ProjectNameAvailable(name, exceptID string) (bool, error) {
return h.caps.st.ProjectNameAvailable(name, exceptID)
}