feat: authenticate bridge callers and declare SDK compatibility
This commit is contained in:
parent
731d6b51d1
commit
e2dcbc1fc3
@ -246,6 +246,9 @@ message BridgeCallRequest {
|
|||||||
string service_name = 1;
|
string service_name = 1;
|
||||||
string method = 2;
|
string method = 2;
|
||||||
bytes payload = 3;
|
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 {
|
message BridgeCallResponse {
|
||||||
|
|||||||
@ -1346,10 +1346,13 @@ func (x *AiToolCallResponse) GetErrorMessage() string {
|
|||||||
// capability; payload encoding is agreed between the two plugins (JSON by
|
// capability; payload encoding is agreed between the two plugins (JSON by
|
||||||
// convention).
|
// convention).
|
||||||
type BridgeCallRequest struct {
|
type BridgeCallRequest struct {
|
||||||
state protoimpl.MessageState `protogen:"open.v1"`
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
ServiceName string `protobuf:"bytes,1,opt,name=service_name,json=serviceName,proto3" json:"service_name,omitempty"`
|
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"`
|
Method string `protobuf:"bytes,2,opt,name=method,proto3" json:"method,omitempty"`
|
||||||
Payload []byte `protobuf:"bytes,3,opt,name=payload,proto3" json:"payload,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
|
unknownFields protoimpl.UnknownFields
|
||||||
sizeCache protoimpl.SizeCache
|
sizeCache protoimpl.SizeCache
|
||||||
}
|
}
|
||||||
@ -1405,6 +1408,13 @@ func (x *BridgeCallRequest) GetPayload() []byte {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (x *BridgeCallRequest) GetCallerPlugin() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.CallerPlugin
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
type BridgeCallResponse struct {
|
type BridgeCallResponse struct {
|
||||||
state protoimpl.MessageState `protogen:"open.v1"`
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
Payload []byte `protobuf:"bytes,1,opt,name=payload,proto3" json:"payload,omitempty"`
|
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" +
|
"paramsJson\"S\n" +
|
||||||
"\x12AiToolCallResponse\x12\x18\n" +
|
"\x12AiToolCallResponse\x12\x18\n" +
|
||||||
"\acontent\x18\x01 \x01(\tR\acontent\x12#\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" +
|
"\x11BridgeCallRequest\x12!\n" +
|
||||||
"\fservice_name\x18\x01 \x01(\tR\vserviceName\x12\x16\n" +
|
"\fservice_name\x18\x01 \x01(\tR\vserviceName\x12\x16\n" +
|
||||||
"\x06method\x18\x02 \x01(\tR\x06method\x12\x18\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" +
|
"\x12BridgeCallResponse\x12\x18\n" +
|
||||||
"\apayload\x18\x01 \x01(\fR\apayload\"Q\n" +
|
"\apayload\x18\x01 \x01(\fR\apayload\"Q\n" +
|
||||||
"\x1cDirectoryPanelSectionRequest\x12\x14\n" +
|
"\x1cDirectoryPanelSectionRequest\x12\x14\n" +
|
||||||
|
|||||||
27
docs/adr/0005-plugin-manifests-declare-sdk-compatibility.md
Normal file
27
docs/adr/0005-plugin-manifests-declare-sdk-compatibility.md
Normal file
@ -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
|
||||||
@ -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
|
||||||
@ -2,6 +2,26 @@ package plugin
|
|||||||
|
|
||||||
import "context"
|
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.
|
// PluginBridge allows plugins to share services with each other.
|
||||||
// Plugins register named services during startup; other plugins look them up at runtime.
|
// Plugins register named services during startup; other plugins look them up at runtime.
|
||||||
//
|
//
|
||||||
|
|||||||
25
plugin/bridge_test.go
Normal file
25
plugin/bridge_test.go
Normal file
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -92,6 +92,7 @@ type ModPublicRoute struct {
|
|||||||
type ModCompat struct {
|
type ModCompat struct {
|
||||||
BlockCore string `toml:"block_core"`
|
BlockCore string `toml:"block_core"`
|
||||||
AdminAPI string `toml:"admin_api"`
|
AdminAPI string `toml:"admin_api"`
|
||||||
|
PluginSDK string `toml:"plugin_sdk"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ModRequirement struct {
|
type ModRequirement struct {
|
||||||
|
|||||||
@ -1,9 +1,12 @@
|
|||||||
package plugin
|
package plugin
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
tomlpkg "github.com/BurntSushi/toml"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestParseModFull_BasicFields(t *testing.T) {
|
func TestParseModFull_BasicFields(t *testing.T) {
|
||||||
@ -279,6 +282,7 @@ version = "0.2.0"
|
|||||||
[compatibility]
|
[compatibility]
|
||||||
block_core = ">=1.5 <2.0"
|
block_core = ">=1.5 <2.0"
|
||||||
admin_api = ">=0.1.2 <0.2.0"
|
admin_api = ">=0.1.2 <0.2.0"
|
||||||
|
plugin_sdk = ">=0.3.7 <0.4.0"
|
||||||
|
|
||||||
[[requires]]
|
[[requires]]
|
||||||
name = "@blockninja/smartblock"
|
name = "@blockninja/smartblock"
|
||||||
@ -301,6 +305,9 @@ version = ">=1.2"
|
|||||||
if m.Compatibility.AdminAPI != ">=0.1.2 <0.2.0" {
|
if m.Compatibility.AdminAPI != ">=0.1.2 <0.2.0" {
|
||||||
t.Errorf("Compat.AdminAPI = %q", m.Compatibility.AdminAPI)
|
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 {
|
if len(m.Requires) != 2 {
|
||||||
t.Fatalf("Requires len = %d, want 2", len(m.Requires))
|
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) {
|
func TestNormalizeTags_HappyPath(t *testing.T) {
|
||||||
got, err := NormalizeTags([]string{"dark", "agency", "serif"})
|
got, err := NormalizeTags([]string{"dark", "agency", "serif"})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@ -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",
|
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())
|
out, err := invokable.InvokeBridge(ctx, req.GetMethod(), req.GetPayload())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, internalError(err.Error())
|
return nil, internalError(err.Error())
|
||||||
|
|||||||
@ -25,6 +25,12 @@ func (c textComponent) Render(_ context.Context, w io.Writer) error {
|
|||||||
return err
|
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 {
|
func fixtureRegistration() plugin.PluginRegistration {
|
||||||
return plugin.PluginRegistration{
|
return plugin.PluginRegistration{
|
||||||
Name: "fixture",
|
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) {
|
func TestDescribeManifest(t *testing.T) {
|
||||||
g := newGuest(fixtureRegistration())
|
g := newGuest(fixtureRegistration())
|
||||||
resp := invokeHook(t, g, abiv1.Hook_HOOK_DESCRIBE, &abiv1.DescribeRequest{HostAbiVersion: 1})
|
resp := invokeHook(t, g, abiv1.Hook_HOOK_DESCRIBE, &abiv1.DescribeRequest{HostAbiVersion: 1})
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user