mirror of
https://github.com/mudler/LocalAI.git
synced 2026-11-05 20:13:23 -05:00
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:
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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
})
|
||||
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user