diff --git a/core/http/endpoints/studio/connectors.go b/core/http/endpoints/studio/connectors.go index c7604f0017..a99532da31 100644 --- a/core/http/endpoints/studio/connectors.go +++ b/core/http/endpoints/studio/connectors.go @@ -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" diff --git a/core/http/endpoints/studio/errmap_test.go b/core/http/endpoints/studio/errmap_test.go index 41b1e9deeb..cadf63866d 100644 --- a/core/http/endpoints/studio/errmap_test.go +++ b/core/http/endpoints/studio/errmap_test.go @@ -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) } }) diff --git a/core/services/studio/connectors/types.go b/core/services/studio/connectors/types.go index 965c4f069a..d98a9532bf 100644 --- a/core/services/studio/connectors/types.go +++ b/core/services/studio/connectors/types.go @@ -85,7 +85,7 @@ const ( MaxNameRunes = 80 MaxAllow = 200 MaxAllowRunes = 64 - MaxPerUser = 10 + MaxPerUser = 50 MaxSendersKept = 200 MaxOptions = 8 MaxOptionRunes = 32 diff --git a/core/services/studio/connectors_agentless_test.go b/core/services/studio/connectors_agentless_test.go index 9f3cbc1896..88c3e0490c 100644 --- a/core/services/studio/connectors_agentless_test.go +++ b/core/services/studio/connectors_agentless_test.go @@ -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 +} diff --git a/core/services/studio/connectorscope.go b/core/services/studio/connectorscope.go index 4792ea864b..0e716324dc 100644 --- a/core/services/studio/connectorscope.go +++ b/core/services/studio/connectorscope.go @@ -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() } } diff --git a/docs/content/features/chats-connectors.md b/docs/content/features/chats-connectors.md index 8cbccf4daa..acdac63c84 100644 --- a/docs/content/features/chats-connectors.md +++ b/docs/content/features/chats-connectors.md @@ -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 diff --git a/swagger/docs.go b/swagger/docs.go index 429ffda99a..4c0dcc338a 100644 --- a/swagger/docs.go +++ b/swagger/docs.go @@ -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" ], diff --git a/swagger/swagger.json b/swagger/swagger.json index 2dd3baffc7..19ba9a4bfd 100644 --- a/swagger/swagger.json +++ b/swagger/swagger.json @@ -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" ], diff --git a/swagger/swagger.yaml b/swagger/swagger.yaml index 2f2c7bea31..7122a9a811 100644 --- a/swagger/swagger.yaml +++ b/swagger/swagger.yaml @@ -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