Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 8 additions & 6 deletions internal/plugin/contributions.go
Original file line number Diff line number Diff line change
Expand Up @@ -147,12 +147,6 @@ func (c InterfaceContract) EffectiveBacking() string {
return c.Backing
}

// DeclaresBacking reports whether the manifest said who serves this service, rather
// than leaving the host to infer it.
func (c InterfaceContract) DeclaresBacking() bool {
return c.Backing != ""
}

func (c InterfaceContract) MarshalJSON() ([]byte, error) {
type stringMethods struct {
Service string `json:"service"`
Expand Down Expand Up @@ -297,6 +291,14 @@ func validateContributions(m Manifest) error {
if c.Backing != "" && m.Schema != ManifestSchemaV2 {
return fmt.Errorf("interface %q backing requires manifest schema v2", c.Service)
}
// A v2 manifest must say who serves each method. Leaving it out is what let a
// plugin declare methods its own artifact could not answer while core quietly
// answered them instead. Rejecting the manifest outright is louder, and safer,
// than resolving it by inference and failing somewhere further downstream.
if m.Schema == ManifestSchemaV2 && c.Backing == "" {
return fmt.Errorf("interface %q must declare backing (%q or %q)",
c.Service, BackingRuntime, BackingCore)
}
// Declaring that core serves a method is a claim on the host's own trust base,
// so it is confined to system plugins. Every v2 manifest already requires a
// trusted-publisher signature, so the declaration is signed by construction.
Expand Down
12 changes: 10 additions & 2 deletions internal/plugin/manifest_v2_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ func validManifestV2() Manifest {
},
Interfaces: []InterfaceContract{{
Service: "latticenet.example/items",
Backing: BackingRuntime,
MethodSpecs: []InterfaceMethod{
{Name: "list", Effect: InterfaceEffectRead, Scopes: []string{"proxy:read"}},
{Name: "save", Effect: InterfaceEffectWrite, Scopes: []string{"proxy:admin"}},
Expand Down Expand Up @@ -149,7 +150,8 @@ func TestManifestV2RejectsLegacyAndIncompleteContracts(t *testing.T) {
{"write without method scopes", func(m *Manifest) { m.Interfaces[0].MethodSpecs[1].Scopes = nil }, "method scopes"},
{"legacy string methods", func(m *Manifest) {
m.Interfaces[0] = InterfaceContract{
Service: "latticenet.example/items", Methods: []string{"list"}, Scopes: []string{"proxy:read"},
Service: "latticenet.example/items", Backing: BackingRuntime,
Methods: []string{"list"}, Scopes: []string{"proxy:read"},
}
}, "typed method"},
{"duplicate interface service", func(m *Manifest) {
Expand Down Expand Up @@ -387,14 +389,20 @@ func TestBackingValidation(t *testing.T) {
}
}

for _, valid := range []string{"", BackingRuntime, BackingCore} {
for _, valid := range []string{BackingRuntime, BackingCore} {
if err := ValidateManifest(newManifest(TypeSystem, valid)); err != nil {
t.Fatalf("backing %q should be valid for a system plugin: %v", valid, err)
}
}
if err := ValidateManifest(newManifest(TypeSystem, "wasm")); err == nil {
t.Fatal("an unknown backing must be rejected")
}
// A v2 manifest that says nothing about who serves a method is what let a plugin
// declare methods its artifact could not answer. It is now rejected outright rather
// than resolved by inference.
if err := ValidateManifest(newManifest(TypeSystem, "")); err == nil {
t.Fatal("a v2 interface without a backing declaration must be rejected")
}
// Claiming core is a claim on the host's own trust base.
if err := ValidateManifest(newManifest(TypeWasm, BackingCore)); err == nil {
t.Fatal("a non-system plugin must not declare core backing")
Expand Down
4 changes: 0 additions & 4 deletions internal/server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -178,10 +178,6 @@ type Server struct {
// party in-core providers register services on it; plugins reach it through
// the capability-scoped broker (HostServices.RPC).
pluginRPC *plugin.RPCRegistry

// undeclaredBackingOnce tracks which legacy services have already been reported
// as core-backed-by-inference, so the warning names each one exactly once.
undeclaredBackingOnce sync.Map
// pluginTrust is the operator policy used by both startup loading and
// pre-install verification endpoints. It is intentionally not client supplied.
pluginTrust plugin.TrustPolicy
Expand Down
1 change: 1 addition & 0 deletions internal/server/server_plugin_assets_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ func newPluginAssetTestServer(t *testing.T) (*Server, http.Handler, []*http.Cook
Compatibility: &plugin.CompatibilitySpec{Server: ">=0.2.1", DashboardHost: ">=1", RuntimeProtocol: ">=1"},
Interfaces: []plugin.InterfaceContract{{
Service: "test.assets/items",
Backing: plugin.BackingRuntime,
MethodSpecs: []plugin.InterfaceMethod{{Name: "list", Effect: plugin.InterfaceEffectRead, Scopes: []string{"proxy:read"}}},
}},
UI: &plugin.ManifestUI{
Expand Down
42 changes: 5 additions & 37 deletions internal/server/server_plugin_invoke.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import (
"encoding/json"
"errors"
"fmt"
"log"
"net/http"
"net/url"
"strconv"
Expand Down Expand Up @@ -277,17 +276,17 @@ func (s *Server) handlePluginCall(w http.ResponseWriter, r *http.Request, p prin
// in-core handler, so a manifest could declare methods its own artifact could not serve
// and no operator could see the difference.
//
// Backing is now a per-service declaration inside the signed manifest, and dispatch
// follows it exactly:
// Backing is a per-service declaration inside the signed manifest, and dispatch follows
// it exactly:
//
// - backing "core": a core provider owned by this plugin must exist. If it does not,
// the call fails — a manifest cannot name core as its backend and have the host
// quietly find something else.
// - backing "runtime": the artifact serves it, and core never answers in its place.
// This is what closes the silent-fallback hole.
// - undeclared: manifests signed before the field existed. Resolved through the legacy
// inference, logged once per service so the remaining ones are visible, and refused
// the moment a re-signed manifest declares its backing.
//
// There is no third case. A v2 manifest that declares no backing is rejected at load
// (see validateInterfaces), so the host never has to guess who owns a method.
func (s *Server) dispatchV2PluginCall(
ctx context.Context,
loaded plugin.Loaded,
Expand All @@ -301,17 +300,6 @@ func (s *Server) dispatchV2PluginCall(
}
coreOwns := s.pluginRPC != nil && s.pluginRPC.Owns(pluginID, service)

if !contract.DeclaresBacking() {
// Reproduce the pre-backing inference exactly, publisher constraint included, so
// an already-signed manifest keeps working unchanged. A third party still cannot
// reach a core provider by naming a service core happens to own.
if coreOwns && loaded.Manifest.Publisher == trustedCorePublisher {
s.warnUndeclaredBacking(pluginID, service)
return s.pluginRPC.CallOperator(ctx, service, method, []byte(payload))
}
return s.callRuntimePluginService(ctx, pluginID, service, method, payload, operatorTargets)
}

switch contract.EffectiveBacking() {
case plugin.BackingCore:
if !coreOwns {
Expand All @@ -330,21 +318,6 @@ func (s *Server) dispatchV2PluginCall(
}
}

// warnUndeclaredBacking logs each legacy core-backed service once, so the set of
// manifests still relying on inference is visible rather than silent.
func (s *Server) warnUndeclaredBacking(pluginID, service string) {
if _, alreadyWarned := s.undeclaredBackingOnce.LoadOrStore(pluginID+"/"+service, true); alreadyWarned {
return
}
const format = "plugin gateway: %s does not declare backing for %q; core is answering it by inference. " +
"Re-sign the manifest with \"backing\":\"core\" — inference will be removed."
if s.logger != nil {
s.logger.Printf(format, pluginID, service)
return
}
log.Printf(format, pluginID, service)
}

func (s *Server) callRuntimePluginService(ctx context.Context, pluginID, service, method string, payload json.RawMessage, operatorTargets []string) ([]byte, error) {
if s.pluginRuntime == nil {
return nil, errors.New("plugin runtime unavailable")
Expand Down Expand Up @@ -534,11 +507,6 @@ var diagnosticPluginActions = map[string]bool{
"health": true,
}

// trustedCorePublisher is the publisher whose manifests may be inferred as core-backed
// while they predate the explicit `backing` declaration. It gates only the legacy path;
// a declared backing is authorized by the manifest signature itself.
const trustedCorePublisher = "latticenet"

// handlePluginInvoke runs one DIAGNOSTIC action on an ACTIVE plugin via the
// runtime (the Tier-2 system runner execs the artifact's {action,payload}->
// {ok,result} protocol). It exists so an operator can interrogate a staged
Expand Down
3 changes: 3 additions & 0 deletions internal/server/server_plugin_invoke_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,7 @@ func TestPluginCallV2UsesExactMethodScopes(t *testing.T) {
Schema: plugin.ManifestSchemaV2, ID: "test.v2", Name: "V2", Type: plugin.TypeSystem,
Interfaces: []plugin.InterfaceContract{{
Service: "test.v2/items",
Backing: plugin.BackingRuntime,
MethodSpecs: []plugin.InterfaceMethod{
{Name: "list", Effect: plugin.InterfaceEffectRead, Scopes: []string{"proxy:read"}},
{Name: "save", Effect: plugin.InterfaceEffectWrite, Scopes: []string{"proxy:admin"}, OperatorTargetFields: []string{"base_url"}},
Expand Down Expand Up @@ -322,6 +323,7 @@ func TestPluginCallV2DispatchesOwnedCoreService(t *testing.T) {
Publisher: "latticenet",
Interfaces: []plugin.InterfaceContract{{
Service: "test.v2-owned/items",
Backing: plugin.BackingCore,
MethodSpecs: []plugin.InterfaceMethod{{
Name: "list", Effect: plugin.InterfaceEffectRead, Scopes: []string{"proxy:read"},
}},
Expand Down Expand Up @@ -368,6 +370,7 @@ func TestPluginCallV2DoesNotDispatchCoreServiceForForeignPublisher(t *testing.T)
Publisher: "other",
Interfaces: []plugin.InterfaceContract{{
Service: "test.v2-foreign/items",
Backing: plugin.BackingRuntime,
MethodSpecs: []plugin.InterfaceMethod{{
Name: "list", Effect: plugin.InterfaceEffectRead, Scopes: []string{"proxy:read"},
}},
Expand Down
Loading