mirror of
https://github.com/mudler/LocalAI.git
synced 2026-11-06 12:33:16 -05:00
fix(connectors): create credentials-only bindings, answer 403 on denial, allow 50 bindings
Creating a binding with no agent (home-assistant, jellyfin, pikvm, github) failed with a 500. The token check for a new binding looked up the agent even when the binding had none, so the host refused it. The check now looks up an agent only when the binding has one. The token is still required and a foreign agent is still refused. A host denial (connectors.ErrForbidden) reached the client as a 500 server_error. The connector endpoints now answer 403 with a fixed text that names no part of the check. A person could have 10 bindings. Several bots plus one credential binding per service need more, so the limit is now 50, like the limit of resources and triggers. The docs and the API description say so. Assisted-by: Claude:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
This commit is contained in:
9 files changed
+101
-7
No files matched your search
@@ -138,6 +138,9 @@ var connectorRules = []errRule{
|
||||
fixed(http.StatusNotFound, typeNotFound, "connector not found", connectors.ErrNotFound),
|
||||
verbatim(http.StatusBadRequest, typeInvalid, connectors.ErrInvalid.Error()+": ", connectors.ErrInvalid),
|
||||
verbatim(http.StatusConflict, typeConnectorOff, "", studiosvc.ErrConnectorsOff),
|
||||
// A host denial is not a failure of the server. The text names no part of the
|
||||
// check (the add-on, the agent, the token), so it tells a caller nothing to probe.
|
||||
fixed(http.StatusForbidden, typeAuthz, "you are not allowed to do this with this connector", connectors.ErrForbidden),
|
||||
}
|
||||
|
||||
func failConnector(c echo.Context, err error) error {
|
||||
@@ -208,7 +211,7 @@ func (h *Handlers) ConnectorKinds(c echo.Context) error {
|
||||
|
||||
// CreateConnector creates a binding.
|
||||
// @Summary Create a connector binding.
|
||||
// @Description Needs the kind (the platform), the bot token (kept sealed, never returned) and an agent of yours that names a model. The token is checked with the platform when its add-on runs: a token that the platform refuses is a 400. reply_mode (off, progress, stream; default progress), conversation (sender, chat, window with window_minutes, thread; default sender), send_files (default true) and reply_language (auto, en, it, de, es, fr, pt-BR; default auto, the language of the sentences the bot writes itself) say how the bot answers. The allowlist is empty at first, so nobody is accepted: add chat IDs, user IDs or @usernames, or set accept_any (anyone who can message the bot then uses the agent's tools; the request that turns it on must also carry confirm_accept_any set to "accept any message", or it is a 400). At most 10 bindings.
|
||||
// @Description Needs the kind (the platform), the bot token (kept sealed, never returned) and an agent of yours that names a model. The token is checked with the platform when its add-on runs: a token that the platform refuses is a 400. reply_mode (off, progress, stream; default progress), conversation (sender, chat, window with window_minutes, thread; default sender), send_files (default true) and reply_language (auto, en, it, de, es, fr, pt-BR; default auto, the language of the sentences the bot writes itself) say how the bot answers. The allowlist is empty at first, so nobody is accepted: add chat IDs, user IDs or @usernames, or set accept_any (anyone who can message the bot then uses the agent's tools; the request that turns it on must also carry confirm_accept_any set to "accept any message", or it is a 400). At most 50 bindings.
|
||||
// @Tags chats
|
||||
// @Param request body schema.ChatsConnectorWrite true "the binding"
|
||||
// @Success 201 {object} schema.ChatsConnector "The binding"
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
|
||||
"github.com/mudler/LocalAI/core/schema"
|
||||
studiosvc "github.com/mudler/LocalAI/core/services/studio"
|
||||
"github.com/mudler/LocalAI/core/services/studio/connectors"
|
||||
"github.com/mudler/LocalAI/core/services/studio/session"
|
||||
"github.com/mudler/LocalAI/core/services/studio/triggers"
|
||||
"github.com/mudler/LocalAI/core/services/studio/workspace"
|
||||
@@ -57,6 +58,8 @@ var _ = Describe("the studio endpoint helpers", func() {
|
||||
{"triggers off", failTrigger, studiosvc.ErrTriggersOff, 409, "triggers_off", studiosvc.ErrTriggersOff.Error()},
|
||||
{"a trigger error falls to the chat table", failTrigger, session.ErrNotFound, 404, "not_found", "conversation not found"},
|
||||
{"an app refusal keeps its reason", failApp, fmt.Errorf("%w: not declared", studiosvc.ErrMCPAppRefused), 403, "mcp_app_refused", "not declared"},
|
||||
{"a host denial for a connector", failConnector, fmt.Errorf("verifying: %w", connectors.ErrForbidden), 403, "authorization_error", "not allowed"},
|
||||
{"a host denial does not name the part that failed", failConnector, fmt.Errorf("agent \"secret-agent\": %w", connectors.ErrForbidden), 403, "authorization_error", "not allowed"},
|
||||
{"an app refusal with no reason", failApp, studiosvc.ErrMCPAppRefused, 403, "mcp_app_refused", ""},
|
||||
{"an MCP content failure", failMCPContent, fmt.Errorf("%w: server said no", studiosvc.ErrMCPContent), 502, "mcp_error", "server said no"},
|
||||
}
|
||||
@@ -66,6 +69,7 @@ var _ = Describe("the studio endpoint helpers", func() {
|
||||
Expect(apiErr.Type).To(Equal(tt.typ), tt.name)
|
||||
Expect(apiErr.Code).To(BeEquivalentTo(tt.status), tt.name)
|
||||
Expect(apiErr.Message).To(ContainSubstring(tt.msg), tt.name)
|
||||
Expect(apiErr.Message).NotTo(ContainSubstring("secret-agent"), tt.name)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -85,7 +85,7 @@ const (
|
||||
MaxNameRunes = 80
|
||||
MaxAllow = 200
|
||||
MaxAllowRunes = 64
|
||||
MaxPerUser = 10
|
||||
MaxPerUser = 50
|
||||
MaxSendersKept = 200
|
||||
MaxOptions = 8
|
||||
MaxOptionRunes = 32
|
||||
|
||||
@@ -2,8 +2,12 @@ package studio_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/mudler/LocalAI/addons/telegram/transport"
|
||||
"github.com/mudler/LocalAI/core/services/addons/gateway"
|
||||
"github.com/mudler/LocalAI/core/services/studio"
|
||||
"github.com/mudler/LocalAI/core/services/studio/connectors"
|
||||
"github.com/mudler/LocalAI/core/services/studio/notify"
|
||||
@@ -100,6 +104,75 @@ var _ = Describe("a credentials-only connector binding", func() {
|
||||
})
|
||||
})
|
||||
|
||||
Describe("the number of bindings of a person", func() {
|
||||
It("is 50, enough for several bots and a credential binding for each service", func() {
|
||||
Expect(connectors.MaxPerUser).To(Equal(50))
|
||||
newRig(true)
|
||||
for i := range connectors.MaxPerUser {
|
||||
in := credOnly(studio.ConnectorInput{})
|
||||
in.Name = fmt.Sprintf("Connection %d", i)
|
||||
_, err := r.conns.CreateConnector(ctx, alice, in)
|
||||
Expect(err).NotTo(HaveOccurred(), "binding %d", i+1)
|
||||
}
|
||||
_, err := r.conns.CreateConnector(ctx, alice, studio.ConnectorInput{Kind: "telegram", Name: "One too many", Token: botToken})
|
||||
Expect(err).To(MatchError(connectors.ErrInvalid))
|
||||
Expect(err).To(MatchError(ContainSubstring("at most 50 bindings")))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("creating with the token check of the platform", func() {
|
||||
// The real check (who is the bot of this token) runs for a binding that is
|
||||
// not stored yet, through the host authority. It must not look up an agent
|
||||
// the binding does not have.
|
||||
verified := func(optional bool) {
|
||||
bot := newScriptedBot()
|
||||
addon := studiobot.Telegram(&transport.API{BaseURL: bot.srv.URL, Client: bot.srv.Client()})
|
||||
addon.Kind.AgentOptional = optional
|
||||
tr := studiobot.NewTransport(addon)
|
||||
st = connStore()
|
||||
r = newConnRigOpts(newShared(), st, tr, connSay("never"), nil, func(o *studio.ConnectorOptions) { o.VerifyToken = studio.VerifyTokenWithMe(asApplication{tr}) })
|
||||
r.bot, r.untrusted = bot, true
|
||||
}
|
||||
It("saves a binding with no agent", func() {
|
||||
verified(true)
|
||||
got, err := r.conns.CreateConnector(ctx, alice, credOnly(studio.ConnectorInput{}))
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(got.CredentialsOnly()).To(BeTrue())
|
||||
})
|
||||
It("still refuses a token that the platform refuses", func() {
|
||||
verified(true)
|
||||
r.bot.setFail("getMe", func() (int, string) {
|
||||
return http.StatusUnauthorized, `{"ok":false,"error_code":401,"description":"Unauthorized"}`
|
||||
})
|
||||
_, err := r.conns.CreateConnector(ctx, alice, credOnly(studio.ConnectorInput{}))
|
||||
Expect(err).To(MatchError(studio.ErrTokenRefused))
|
||||
})
|
||||
It("still needs an agent when the kind does not declare agentOptional", func() {
|
||||
verified(false)
|
||||
_, err := r.conns.CreateConnector(ctx, alice, credOnly(studio.ConnectorInput{}))
|
||||
Expect(err).To(MatchError(connectors.ErrInvalid))
|
||||
})
|
||||
It("still refuses an agent that is not the owner's", func() {
|
||||
verified(true)
|
||||
_, err := r.conns.CreateConnector(ctx, alice, credOnly(studio.ConnectorInput{AgentID: "someone-elses"}))
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
It("authorizes the check of a new binding by its token, and by its agent only when it has one", func() {
|
||||
verified(true)
|
||||
me := func(scope studio.ConnectorCallScope, token string) error {
|
||||
_, err := r.conns.AuthorizeConnector(ctx, scope, contract.ConnectorRequest{Op: contract.ConnectorOpMe, Kind: "telegram", Token: token})
|
||||
return err
|
||||
}
|
||||
scope := studio.ConnectorCallScope{Purpose: studio.ConnectorPurposeVerifyToken, Principal: alice, AddonID: "telegram", Kind: "telegram"}
|
||||
Expect(me(scope, botToken)).To(Succeed())
|
||||
Expect(me(scope, "")).To(MatchError(connectors.ErrForbidden), "the token is still required")
|
||||
scope.AgentID = "someone-elses"
|
||||
Expect(me(scope, botToken)).To(MatchError(connectors.ErrForbidden), "a foreign agent is still refused")
|
||||
scope.AgentID = r.agentID()
|
||||
Expect(me(scope, botToken)).To(Succeed())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("a record that is on without an agent", func() {
|
||||
BeforeEach(func() { newRig(true); b = stored() })
|
||||
|
||||
@@ -143,3 +216,15 @@ var _ = Describe("a credentials-only connector binding", func() {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// asApplication maps a refusal of the gateway to the one of the connectors, as
|
||||
// the application transport does.
|
||||
type asApplication struct{ *studiobot.BotTransport }
|
||||
|
||||
func (t asApplication) Call(ctx context.Context, scope studio.ConnectorCallScope, req contract.ConnectorRequest) (contract.ConnectorResponse, error) {
|
||||
resp, err := t.BotTransport.Call(ctx, scope, req)
|
||||
if errors.Is(err, gateway.ErrForbidden) {
|
||||
return resp, connectors.ErrForbidden
|
||||
}
|
||||
return resp, err
|
||||
}
|
||||
@@ -64,8 +64,10 @@ func (c *Connectors) AuthorizeConnector(ctx context.Context, s ConnectorCallScop
|
||||
return deny()
|
||||
}
|
||||
}
|
||||
// A new binding is not stored yet, so its agent is checked here. A
|
||||
// credentials-only binding has none, and the token check still ran above.
|
||||
if s.BindingID == "" {
|
||||
if err := c.checkAgent(ctx, s.Principal, s.AgentID); err != nil {
|
||||
if err := c.checkAgentIfAny(ctx, s.Principal, s.AgentID); err != nil {
|
||||
return deny()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,7 +131,7 @@ gets one sentence (for example "I cannot listen to voice messages here: no
|
||||
speech-to-text model is set up") and no turn starts.
|
||||
|
||||
`GET`, `PUT` and `DELETE /api/chats/connectors/{id}` read, replace and delete a
|
||||
binding. `POST .../enable` and `.../disable` switch it. You can have 10 bindings.
|
||||
binding. `POST .../enable` and `.../disable` switch it. You can have 50 bindings.
|
||||
A binding says `active: false` and gives a `reason` while its add-on does not run.
|
||||
|
||||
## GitHub
|
||||
|
||||
+1
-1
@@ -5756,7 +5756,7 @@ const docTemplate = `{
|
||||
}
|
||||
},
|
||||
"post": {
|
||||
"description": "Needs the kind (the platform), the bot token (kept sealed, never returned) and an agent of yours that names a model. The token is checked with the platform when its add-on runs: a token that the platform refuses is a 400. reply_mode (off, progress, stream; default progress), conversation (sender, chat, window with window_minutes, thread; default sender), send_files (default true) and reply_language (auto, en, it, de, es, fr, pt-BR; default auto, the language of the sentences the bot writes itself) say how the bot answers. The allowlist is empty at first, so nobody is accepted: add chat IDs, user IDs or @usernames, or set accept_any (anyone who can message the bot then uses the agent's tools; the request that turns it on must also carry confirm_accept_any set to \"accept any message\", or it is a 400). At most 10 bindings.",
|
||||
"description": "Needs the kind (the platform), the bot token (kept sealed, never returned) and an agent of yours that names a model. The token is checked with the platform when its add-on runs: a token that the platform refuses is a 400. reply_mode (off, progress, stream; default progress), conversation (sender, chat, window with window_minutes, thread; default sender), send_files (default true) and reply_language (auto, en, it, de, es, fr, pt-BR; default auto, the language of the sentences the bot writes itself) say how the bot answers. The allowlist is empty at first, so nobody is accepted: add chat IDs, user IDs or @usernames, or set accept_any (anyone who can message the bot then uses the agent's tools; the request that turns it on must also carry confirm_accept_any set to \"accept any message\", or it is a 400). At most 50 bindings.",
|
||||
"tags": [
|
||||
"chats"
|
||||
],
|
||||
|
||||
@@ -5753,7 +5753,7 @@
|
||||
}
|
||||
},
|
||||
"post": {
|
||||
"description": "Needs the kind (the platform), the bot token (kept sealed, never returned) and an agent of yours that names a model. The token is checked with the platform when its add-on runs: a token that the platform refuses is a 400. reply_mode (off, progress, stream; default progress), conversation (sender, chat, window with window_minutes, thread; default sender), send_files (default true) and reply_language (auto, en, it, de, es, fr, pt-BR; default auto, the language of the sentences the bot writes itself) say how the bot answers. The allowlist is empty at first, so nobody is accepted: add chat IDs, user IDs or @usernames, or set accept_any (anyone who can message the bot then uses the agent's tools; the request that turns it on must also carry confirm_accept_any set to \"accept any message\", or it is a 400). At most 10 bindings.",
|
||||
"description": "Needs the kind (the platform), the bot token (kept sealed, never returned) and an agent of yours that names a model. The token is checked with the platform when its add-on runs: a token that the platform refuses is a 400. reply_mode (off, progress, stream; default progress), conversation (sender, chat, window with window_minutes, thread; default sender), send_files (default true) and reply_language (auto, en, it, de, es, fr, pt-BR; default auto, the language of the sentences the bot writes itself) say how the bot answers. The allowlist is empty at first, so nobody is accepted: add chat IDs, user IDs or @usernames, or set accept_any (anyone who can message the bot then uses the agent's tools; the request that turns it on must also carry confirm_accept_any set to \"accept any message\", or it is a 400). At most 50 bindings.",
|
||||
"tags": [
|
||||
"chats"
|
||||
],
|
||||
|
||||
@@ -17400,7 +17400,7 @@ paths:
|
||||
The allowlist is empty at first, so nobody is accepted: add chat IDs, user
|
||||
IDs or @usernames, or set accept_any (anyone who can message the bot then
|
||||
uses the agent''s tools; the request that turns it on must also carry confirm_accept_any
|
||||
set to "accept any message", or it is a 400). At most 10 bindings.'
|
||||
set to "accept any message", or it is a 400). At most 50 bindings.'
|
||||
parameters:
|
||||
- description: the binding
|
||||
in: body
|
||||
|
||||
Reference in new issue
Block a user