From e2dcbc1fc3b608c0db7b155c68b92ec9442374b1 Mon Sep 17 00:00:00 2001 From: Alex Dunmow Date: Thu, 20 Aug 2026 00:24:49 +0800 Subject: [PATCH] feat: authenticate bridge callers and declare SDK compatibility --- abi/proto/v1/invoke.proto | 3 ++ abi/v1/invoke.pb.go | 23 ++++++++--- ...gin-manifests-declare-sdk-compatibility.md | 27 +++++++++++++ ...s-receive-authenticated-caller-identity.md | 27 +++++++++++++ plugin/bridge.go | 20 ++++++++++ plugin/bridge_test.go | 25 ++++++++++++ plugin/mod.go | 1 + plugin/mod_test.go | 34 ++++++++++++++++ plugin/wasmguest/dispatch.go | 1 + plugin/wasmguest/dispatch_test.go | 39 +++++++++++++++++++ 10 files changed, 194 insertions(+), 6 deletions(-) create mode 100644 docs/adr/0005-plugin-manifests-declare-sdk-compatibility.md create mode 100644 docs/adr/0006-bridge-providers-receive-authenticated-caller-identity.md create mode 100644 plugin/bridge_test.go diff --git a/abi/proto/v1/invoke.proto b/abi/proto/v1/invoke.proto index adb0229..7b65c21 100644 --- a/abi/proto/v1/invoke.proto +++ b/abi/proto/v1/invoke.proto @@ -246,6 +246,9 @@ message BridgeCallRequest { string service_name = 1; string method = 2; bytes payload = 3; + // Host-authenticated identity of the consumer plugin. The host derives this + // from the loaded caller; providers must not trust identity in payload. + string caller_plugin = 4; } message BridgeCallResponse { diff --git a/abi/v1/invoke.pb.go b/abi/v1/invoke.pb.go index adf4d36..72f5c57 100644 --- a/abi/v1/invoke.pb.go +++ b/abi/v1/invoke.pb.go @@ -1346,10 +1346,13 @@ func (x *AiToolCallResponse) GetErrorMessage() string { // capability; payload encoding is agreed between the two plugins (JSON by // convention). type BridgeCallRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - ServiceName string `protobuf:"bytes,1,opt,name=service_name,json=serviceName,proto3" json:"service_name,omitempty"` - Method string `protobuf:"bytes,2,opt,name=method,proto3" json:"method,omitempty"` - Payload []byte `protobuf:"bytes,3,opt,name=payload,proto3" json:"payload,omitempty"` + state protoimpl.MessageState `protogen:"open.v1"` + ServiceName string `protobuf:"bytes,1,opt,name=service_name,json=serviceName,proto3" json:"service_name,omitempty"` + Method string `protobuf:"bytes,2,opt,name=method,proto3" json:"method,omitempty"` + Payload []byte `protobuf:"bytes,3,opt,name=payload,proto3" json:"payload,omitempty"` + // Host-authenticated identity of the consumer plugin. The host derives this + // from the loaded caller; providers must not trust identity in payload. + CallerPlugin string `protobuf:"bytes,4,opt,name=caller_plugin,json=callerPlugin,proto3" json:"caller_plugin,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -1405,6 +1408,13 @@ func (x *BridgeCallRequest) GetPayload() []byte { return nil } +func (x *BridgeCallRequest) GetCallerPlugin() string { + if x != nil { + return x.CallerPlugin + } + return "" +} + type BridgeCallResponse struct { state protoimpl.MessageState `protogen:"open.v1"` Payload []byte `protobuf:"bytes,1,opt,name=payload,proto3" json:"payload,omitempty"` @@ -1842,11 +1852,12 @@ const file_v1_invoke_proto_rawDesc = "" + "paramsJson\"S\n" + "\x12AiToolCallResponse\x12\x18\n" + "\acontent\x18\x01 \x01(\tR\acontent\x12#\n" + - "\rerror_message\x18\x02 \x01(\tR\ferrorMessage\"h\n" + + "\rerror_message\x18\x02 \x01(\tR\ferrorMessage\"\x8d\x01\n" + "\x11BridgeCallRequest\x12!\n" + "\fservice_name\x18\x01 \x01(\tR\vserviceName\x12\x16\n" + "\x06method\x18\x02 \x01(\tR\x06method\x12\x18\n" + - "\apayload\x18\x03 \x01(\fR\apayload\".\n" + + "\apayload\x18\x03 \x01(\fR\apayload\x12#\n" + + "\rcaller_plugin\x18\x04 \x01(\tR\fcallerPlugin\".\n" + "\x12BridgeCallResponse\x12\x18\n" + "\apayload\x18\x01 \x01(\fR\apayload\"Q\n" + "\x1cDirectoryPanelSectionRequest\x12\x14\n" + diff --git a/docs/adr/0005-plugin-manifests-declare-sdk-compatibility.md b/docs/adr/0005-plugin-manifests-declare-sdk-compatibility.md new file mode 100644 index 0000000..d75e601 --- /dev/null +++ b/docs/adr/0005-plugin-manifests-declare-sdk-compatibility.md @@ -0,0 +1,27 @@ +# Plugin manifests declare Plugin SDK compatibility + +The historical `block_core` compatibility field predates the standalone +Plugin SDK and is ambiguous: CMS core and the plugin-facing SDK have separate +release cycles. Interpreting the same constraint against both versions can +select an artifact that the host cannot load or reject one that is compatible. + +Decision: `[compatibility]` gains an optional `plugin_sdk` string containing a +semantic-version constraint for the plugin-facing SDK/ABI, for example +`plugin_sdk = ">=0.3.7"`. Manifest parsing preserves the value verbatim; +registry and host resolvers own constraint validation and matching. + +An omitted or empty value is undeclared compatibility, not a wildcard. +Automated resolution against a host SDK version must fail closed, except for +an explicitly bounded migration policy for releases published before this +field existed. `block_core` remains a separate historical constraint and is +not reinterpreted as the CMS application's own version. + +Consequences: + +- Plugins can state the actual SDK/ABI range they require. +- CMS core can evolve independently of the public Plugin SDK. +- New releases must declare `plugin_sdk` to participate in automatic + SDK-compatible selection. + +Keywords: plugin.mod, compatibility, plugin_sdk, PluginSDK, ABI, semantic +version, plugin resolver, fail closed diff --git a/docs/adr/0006-bridge-providers-receive-authenticated-caller-identity.md b/docs/adr/0006-bridge-providers-receive-authenticated-caller-identity.md new file mode 100644 index 0000000..77f9293 --- /dev/null +++ b/docs/adr/0006-bridge-providers-receive-authenticated-caller-identity.md @@ -0,0 +1,27 @@ +# Bridge providers receive authenticated caller identity + +Bridge payloads are guest-controlled. A provider that uses a payload field as +an ownership namespace lets one plugin impersonate another plugin and mutate +or claim its managed records. The consumer-to-host `BridgeInvokeRequest` +cannot safely carry identity because the consumer constructs that message. + +Decision: the host derives the caller plugin from the loaded module or native +plugin registration. It passes that identity to the provider in +`BridgeCallRequest.caller_plugin` and, for Go providers, through +`plugin.WithBridgeCallerPlugin`. Providers read it with +`plugin.BridgeCallerPlugin` and fail closed when an ownership-sensitive call +has no authenticated caller. + +The new host-to-provider protobuf field is additive. Older providers ignore +it. New ownership-sensitive providers intentionally reject calls from older +hosts that cannot authenticate a caller. Hosts must never copy a caller name +from opaque bridge payloads or a consumer-authored capability field. + +Consequences: + +- Providers can derive durable ownership from an authenticated principal. +- Bridge payload schemas do not need security-sensitive source fields. +- Native and Wasm providers observe the same caller context contract. + +Keywords: plugin bridge, caller identity, authentication, ownership, +BridgeCallRequest, WithBridgeCallerPlugin, Wasm, confused deputy diff --git a/plugin/bridge.go b/plugin/bridge.go index 6825f3a..7b57500 100644 --- a/plugin/bridge.go +++ b/plugin/bridge.go @@ -2,6 +2,26 @@ package plugin import "context" +type bridgeCallerPluginContextKey struct{} + +// WithBridgeCallerPlugin records the host-authenticated plugin name of a +// bridge caller. Hosts and ABI adapters must derive pluginName from the loaded +// caller, never from guest-controlled request data. +func WithBridgeCallerPlugin(ctx context.Context, pluginName string) context.Context { + return context.WithValue(ctx, bridgeCallerPluginContextKey{}, pluginName) +} + +// BridgeCallerPlugin returns the host-authenticated plugin name of the +// current bridge caller. Ownership-sensitive providers should fail closed +// when no caller identity is present. +func BridgeCallerPlugin(ctx context.Context) (string, bool) { + if ctx == nil { + return "", false + } + pluginName, ok := ctx.Value(bridgeCallerPluginContextKey{}).(string) + return pluginName, ok && pluginName != "" +} + // PluginBridge allows plugins to share services with each other. // Plugins register named services during startup; other plugins look them up at runtime. // diff --git a/plugin/bridge_test.go b/plugin/bridge_test.go new file mode 100644 index 0000000..9afc40b --- /dev/null +++ b/plugin/bridge_test.go @@ -0,0 +1,25 @@ +package plugin + +import ( + "context" + "testing" +) + +func TestBridgeCallerPlugin(t *testing.T) { + if got, ok := BridgeCallerPlugin(context.Background()); ok || got != "" { + t.Fatalf("BridgeCallerPlugin(background) = %q, %t, want empty, false", got, ok) + } + + ctx := WithBridgeCallerPlugin(context.Background(), "website") + if got, ok := BridgeCallerPlugin(ctx); !ok || got != "website" { + t.Fatalf("BridgeCallerPlugin(ctx) = %q, %t, want website, true", got, ok) + } +} + +func TestWithBridgeCallerPluginEmptyDoesNotAuthenticateCaller(t *testing.T) { + ctx := WithBridgeCallerPlugin(context.Background(), "spoofed") + ctx = WithBridgeCallerPlugin(ctx, "") + if got, ok := BridgeCallerPlugin(ctx); ok || got != "" { + t.Fatalf("BridgeCallerPlugin(ctx) = %q, %t, want empty, false", got, ok) + } +} diff --git a/plugin/mod.go b/plugin/mod.go index 515edf3..e3319a8 100644 --- a/plugin/mod.go +++ b/plugin/mod.go @@ -92,6 +92,7 @@ type ModPublicRoute struct { type ModCompat struct { BlockCore string `toml:"block_core"` AdminAPI string `toml:"admin_api"` + PluginSDK string `toml:"plugin_sdk"` } type ModRequirement struct { diff --git a/plugin/mod_test.go b/plugin/mod_test.go index ab5ab83..1884194 100644 --- a/plugin/mod_test.go +++ b/plugin/mod_test.go @@ -1,9 +1,12 @@ package plugin import ( + "bytes" "fmt" "strings" "testing" + + tomlpkg "github.com/BurntSushi/toml" ) func TestParseModFull_BasicFields(t *testing.T) { @@ -279,6 +282,7 @@ version = "0.2.0" [compatibility] block_core = ">=1.5 <2.0" admin_api = ">=0.1.2 <0.2.0" +plugin_sdk = ">=0.3.7 <0.4.0" [[requires]] name = "@blockninja/smartblock" @@ -301,6 +305,9 @@ version = ">=1.2" if m.Compatibility.AdminAPI != ">=0.1.2 <0.2.0" { t.Errorf("Compat.AdminAPI = %q", m.Compatibility.AdminAPI) } + if m.Compatibility.PluginSDK != ">=0.3.7 <0.4.0" { + t.Errorf("Compat.PluginSDK = %q", m.Compatibility.PluginSDK) + } if len(m.Requires) != 2 { t.Fatalf("Requires len = %d, want 2", len(m.Requires)) } @@ -312,6 +319,33 @@ version = ">=1.2" } } +func TestModCompatPluginSDKRoundTrip(t *testing.T) { + t.Parallel() + + want := &ModFile{ + Plugin: ModPlugin{Name: "wiki", Version: "0.2.0"}, + Compatibility: &ModCompat{ + BlockCore: ">=0.3.4", + AdminAPI: ">=0.1.2", + PluginSDK: ">=0.3.7 <0.4.0", + }, + } + var encoded bytes.Buffer + if err := tomlpkg.NewEncoder(&encoded).Encode(want); err != nil { + t.Fatalf("encode plugin.mod: %v", err) + } + if !strings.Contains(encoded.String(), `plugin_sdk = ">=0.3.7 <0.4.0"`) { + t.Fatalf("encoded plugin.mod omitted plugin_sdk:\n%s", encoded.String()) + } + got, err := ParseModFull(encoded.Bytes()) + if err != nil { + t.Fatalf("ParseModFull(round trip): %v", err) + } + if got.Compatibility == nil || got.Compatibility.PluginSDK != want.Compatibility.PluginSDK { + t.Fatalf("round-trip PluginSDK = %#v, want %q", got.Compatibility, want.Compatibility.PluginSDK) + } +} + func TestNormalizeTags_HappyPath(t *testing.T) { got, err := NormalizeTags([]string{"dark", "agency", "serif"}) if err != nil { diff --git a/plugin/wasmguest/dispatch.go b/plugin/wasmguest/dispatch.go index 1d96d53..73300bc 100644 --- a/plugin/wasmguest/dispatch.go +++ b/plugin/wasmguest/dispatch.go @@ -713,6 +713,7 @@ func (g *guest) bridgeCall(ctx context.Context, payload []byte) (proto.Message, Message: "bridge service " + req.GetServiceName() + " does not implement plugin.BridgeInvokable", } } + ctx = plugin.WithBridgeCallerPlugin(ctx, req.GetCallerPlugin()) out, err := invokable.InvokeBridge(ctx, req.GetMethod(), req.GetPayload()) if err != nil { return nil, internalError(err.Error()) diff --git a/plugin/wasmguest/dispatch_test.go b/plugin/wasmguest/dispatch_test.go index 472688f..4a99bde 100644 --- a/plugin/wasmguest/dispatch_test.go +++ b/plugin/wasmguest/dispatch_test.go @@ -25,6 +25,12 @@ func (c textComponent) Render(_ context.Context, w io.Writer) error { return err } +type bridgeInvokableFunc func(context.Context, string, []byte) ([]byte, error) + +func (f bridgeInvokableFunc) InvokeBridge(ctx context.Context, method string, payload []byte) ([]byte, error) { + return f(ctx, method, payload) +} + func fixtureRegistration() plugin.PluginRegistration { return plugin.PluginRegistration{ Name: "fixture", @@ -124,6 +130,39 @@ func TestServeInstallsRuntime(t *testing.T) { } } +func TestBridgeCallProvidesAuthenticatedCallerContext(t *testing.T) { + g := newGuest(fixtureRegistration()) + g.services.Bridge.RegisterService("fixture", "content", bridgeInvokableFunc( + func(ctx context.Context, method string, payload []byte) ([]byte, error) { + caller, ok := plugin.BridgeCallerPlugin(ctx) + if !ok || caller != "website" { + t.Fatalf("BridgeCallerPlugin(ctx) = %q, %t, want website, true", caller, ok) + } + if method != "ApplyBundle" || string(payload) != `{"revision":"1"}` { + t.Fatalf("bridge call = %q %q", method, payload) + } + return []byte(`{"created":1}`), nil + }, + )) + + resp := invokeHook(t, g, abiv1.Hook_HOOK_BRIDGE_CALL, &abiv1.BridgeCallRequest{ + ServiceName: "content", + Method: "ApplyBundle", + Payload: []byte(`{"revision":"1"}`), + CallerPlugin: "website", + }) + if resp.GetError() != nil { + t.Fatalf("bridge call returned error: %v", resp.GetError()) + } + bridgeResp := &abiv1.BridgeCallResponse{} + if err := proto.Unmarshal(resp.GetPayload(), bridgeResp); err != nil { + t.Fatalf("unmarshal BridgeCallResponse: %v", err) + } + if got := string(bridgeResp.GetPayload()); got != `{"created":1}` { + t.Fatalf("bridge payload = %q, want report", got) + } +} + func TestDescribeManifest(t *testing.T) { g := newGuest(fixtureRegistration()) resp := invokeHook(t, g, abiv1.Hook_HOOK_DESCRIBE, &abiv1.DescribeRequest{HostAbiVersion: 1})