fix(studio): let a binding read the history of its thread

AuthorizeConnector had no case for the op history, so the default
branch refused it. A mention in a Slack thread then logged "connectors:
forbidden" and the first turn of the new chat got no thread block.

History now follows the rules of a send: the binding is the whole
authority, the token comes from owner storage and a disabled binding
keeps it. Only the poll stops with the binding.

The studio specs ran over a transport that skipped the scope check, so
none of them saw the refusal. The rig now authorizes every call with the
real manager, and a new spec table pins each op of the contract.

Assisted-by: Claude:claude-sonnet-5-5
This commit is contained in:
Ettore Di Giacinto committed 2026-11-04 09:12:09 +00:00
1 parent 81a56608d1
commit e6fe327203
5 files changed
+114 -2

No files matched your search

@@ -385,7 +385,7 @@ var _ = Describe("connector owner routing", func() {
b, e := bindings.GetBinding(ctx, original.ID)
Expect(e).NotTo(HaveOccurred())
scope := studio.BindingConnectorScope(b)
for _, op := range []string{contract.ConnectorOpSend, contract.ConnectorOpEdit, contract.ConnectorOpTyping, contract.ConnectorOpDraft} {
for _, op := range []string{contract.ConnectorOpSend, contract.ConnectorOpEdit, contract.ConnectorOpTyping, contract.ConnectorOpDraft, contract.ConnectorOpHistory} {
req := contract.ConnectorRequest{Op: op, Kind: "slack", Chat: "chat", Thread: "thread", Text: "private text", MessageID: "working", DraftID: 7, Media: "ref"}
before = len(source.requests())
_, e = tr.Call(ctx, scope, req)
+1
View File
@@ -314,6 +314,7 @@ func newConnRigOpts(sh *shared, store connectors.Store, tr *studiobot.BotTranspo
c, err := studio.NewConnectors(r.svc, o)
Expect(err).NotTo(HaveOccurred())
DeferCleanup(c.Close)
tr.UseAuthority(c) // as the application does: every call is authorized by the stored binding
r.svc.SetConnectors(c) // as the application does: the chats see their bindings
ctx, cancel := context.WithCancel(context.Background())
DeferCleanup(cancel)
+4 -1
View File
@@ -76,7 +76,10 @@ func (c *Connectors) AuthorizeConnector(ctx context.Context, s ConnectorCallScop
if !b.Enabled {
return deny()
}
case contract.ConnectorOpMe, contract.ConnectorOpSend, contract.ConnectorOpEdit, contract.ConnectorOpTyping, contract.ConnectorOpDraft, contract.ConnectorOpMedia, contract.ConnectorOpSendFile:
case contract.ConnectorOpMe, contract.ConnectorOpSend, contract.ConnectorOpEdit, contract.ConnectorOpTyping, contract.ConnectorOpDraft, contract.ConnectorOpMedia, contract.ConnectorOpSendFile, contract.ConnectorOpHistory:
// History reads the thread named by the request with the stored token,
// as a send writes to it: the binding is the whole authority, and a
// disabled binding keeps it, like for a send.
default:
return deny()
}
@@ -0,0 +1,95 @@
package studio_test
import (
"context"
"github.com/mudler/LocalAI/core/services/studio"
"github.com/mudler/LocalAI/core/services/studio/connectors"
"github.com/mudler/LocalAI/pkg/addons/contract"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
// The authority of the host is the only gate between an add-on call and the
// token of a binding. These specs pin which operations a binding scope may run.
var _ = Describe("the authorization of a connector call", func() {
ctx := context.Background()
var (
r *connRig
b connectors.Binding
)
BeforeEach(func() {
r = newConnRig(newShared(), connStore(), newScriptedBot(), connSay("ok"), nil)
b = r.bind(studio.ConnectorInput{Allow: []string{"11"}})
})
history := func() contract.ConnectorRequest {
return contract.ConnectorRequest{Op: contract.ConnectorOpHistory, Kind: b.Kind, Chat: "C1", Thread: "T1", Limit: 20, Before: "m5"}
}
It("runs the history of a thread for the binding, with the stored token and the request untouched", func() {
got, err := r.conns.AuthorizeConnector(ctx, studio.BindingConnectorScope(b), history())
Expect(err).NotTo(HaveOccurred())
want := history()
want.Token = botToken
Expect(got).To(Equal(want))
})
It("ignores a token that the caller puts in a history request", func() {
req := history()
req.Token = "not-the-token"
got, err := r.conns.AuthorizeConnector(ctx, studio.BindingConnectorScope(b), req)
Expect(err).NotTo(HaveOccurred())
Expect(got.Token).To(Equal(botToken))
})
It("refuses the history for a scope that does not match the stored binding", func() {
for name, mutate := range map[string]func(*studio.ConnectorCallScope){
"owner": func(s *studio.ConnectorCallScope) { s.Principal = "mallory" },
"add-on": func(s *studio.ConnectorCallScope) { s.AddonID = "other" },
"agent": func(s *studio.ConnectorCallScope) { s.AgentID = "other" },
"binding": func(s *studio.ConnectorCallScope) { s.BindingID = "missing" },
"none": func(s *studio.ConnectorCallScope) { s.BindingID = "" },
} {
s := studio.BindingConnectorScope(b)
mutate(&s)
_, err := r.conns.AuthorizeConnector(ctx, s, history())
Expect(err).To(MatchError(connectors.ErrForbidden), name)
}
req := history()
req.Kind = "other"
_, err := r.conns.AuthorizeConnector(ctx, studio.BindingConnectorScope(b), req)
Expect(err).To(MatchError(connectors.ErrForbidden), "a request of another kind")
s := studio.BindingConnectorScope(b)
s.Purpose = studio.ConnectorPurposeVerifyToken
_, err = r.conns.AuthorizeConnector(ctx, s, history())
Expect(err).To(MatchError(connectors.ErrForbidden), "a token check runs only the op me")
})
It("treats a disabled binding as it treats a send: only the poll stops", func() {
_, err := r.conns.SetConnectorEnabled(ctx, alice, b.ID, false)
Expect(err).NotTo(HaveOccurred())
off, err := r.store.GetBinding(ctx, b.ID)
Expect(err).NotTo(HaveOccurred())
s := studio.BindingConnectorScope(off)
send := contract.ConnectorRequest{Op: contract.ConnectorOpSend, Kind: off.Kind, Chat: "C1", Text: "hi"}
_, sendErr := r.conns.AuthorizeConnector(ctx, s, send)
_, histErr := r.conns.AuthorizeConnector(ctx, s, history())
Expect(sendErr).NotTo(HaveOccurred())
Expect(histErr).NotTo(HaveOccurred())
_, pollErr := r.conns.AuthorizeConnector(ctx, s, contract.ConnectorRequest{Op: contract.ConnectorOpPoll, Kind: off.Kind})
Expect(pollErr).To(MatchError(connectors.ErrForbidden))
})
It("allows every operation of the contract for an enabled binding and refuses an unknown one", func() {
s := studio.BindingConnectorScope(b)
for _, op := range []string{
contract.ConnectorOpPoll, contract.ConnectorOpSend, contract.ConnectorOpEdit, contract.ConnectorOpTyping, contract.ConnectorOpMe,
contract.ConnectorOpMedia, contract.ConnectorOpSendFile, contract.ConnectorOpDraft, contract.ConnectorOpHistory,
} {
_, err := r.conns.AuthorizeConnector(ctx, s, contract.ConnectorRequest{Op: op, Kind: b.Kind})
Expect(err).NotTo(HaveOccurred(), op)
}
_, err := r.conns.AuthorizeConnector(ctx, s, contract.ConnectorRequest{Op: "export", Kind: b.Kind})
Expect(err).To(MatchError(connectors.ErrForbidden))
})
})
+13
View File
@@ -148,6 +148,19 @@ func (b *BotTransport) EnableTransfers(t *hostops.Transfers) (stop func()) {
}
}
// UseAuthority makes every call go through the real gateway with the given
// authority, as in the application. Without it a transport that moves no file
// calls the add-on directly and checks no scope.
func (b *BotTransport) UseAuthority(a gateway.ConnectorAuthority) {
b.mu.Lock()
defer b.mu.Unlock()
if b.gateway == nil {
b.gateway = &gateway.Gateway{Connectors: gatewaySource{b}, ConnectorAuthority: a}
return
}
b.gateway.ConnectorAuthority = a
}
type allowAll struct{}
func (allowAll) AuthorizeConnector(_ context.Context, _ studio.ConnectorCallScope, r contract.ConnectorRequest) (contract.ConnectorRequest, error) {