Files
LocalAI/core/http/route_authmodes_test.go
T
Ettore Di Giacinto 1834d4fd72 refactor(addons): make manifest api 2 the only version
No release ever shipped add-ons, so there is no installed base on api 1.
The manifest must now declare api {"min": 2, "max": 2}; a manifest with
api 1, no api or a wider range is refused with a message that names the
range.

Every field that was gated on api 2 is now part of the contract, so the
Min*API constants and their gates are gone, and so are HostAPIMin and
the apiMin host field. Three older-manifest shims go with them: the
resource listing the host implied for one add-on, the legacyArgs hint
for arguments an older version of a tool took, and the tests that
checked the gates. All shipped manifests and the test fixtures declare
api 2.

Assisted-by: Claude:claude-sonnet-5-5 go test
2026-11-03 16:15:07 +00:00

363 lines
14 KiB
Go

//go:build auth
package http_test
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
goruntime "runtime"
"sort"
"strings"
"github.com/labstack/echo/v4"
"github.com/mudler/LocalAI/core/application"
"github.com/mudler/LocalAI/core/config"
. "github.com/mudler/LocalAI/core/http"
"github.com/mudler/LocalAI/core/http/auth"
"github.com/mudler/LocalAI/core/services/addons/gateway"
"github.com/mudler/LocalAI/pkg/addons/contract"
"github.com/mudler/LocalAI/pkg/system"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
const (
authModesHMACSecret = "test-secret-for-auth-modes"
authModesLegacyKey = "legacy-key"
)
type authModesCredential struct {
name string
header func(req *http.Request)
}
type authModesServer struct {
e *echo.Echo
creds map[string]authModesCredential
}
// routesUnderTest lists every chats and add-on route the server registers.
func (s *authModesServer) routesUnderTest() []*echo.Route {
seen := map[string]bool{}
found := []*echo.Route{}
for _, r := range s.e.Routes() {
if !isChatsRoute(r.Path) && !strings.HasPrefix(r.Path, "/api/addons") {
continue
}
key := r.Method + " " + r.Path
if seen[key] || r.Method == http.MethodOptions || r.Method == http.MethodHead {
continue
}
seen[key] = true
found = append(found, r)
}
sort.Slice(found, func(i, j int) bool {
return found[i].Path+" "+found[i].Method < found[j].Path+" "+found[j].Method
})
return found
}
// isChatsRoute says whether a path belongs to the chats surface: the chats and
// the change feed of their files.
func isChatsRoute(p string) bool {
return strings.HasPrefix(p, "/api/chats") || strings.HasPrefix(p, "/api/workspace")
}
func authModesConcretize(pattern string) string {
parts := strings.Split(pattern, "/")
for i, p := range parts {
if strings.HasPrefix(p, ":") || p == "*" {
parts[i] = "test"
}
}
return strings.Join(parts, "/")
}
// status sends the request of one route with the credential and returns the
// status code. Streaming routes are cancelled right after the headers.
func (s *authModesServer) status(r *echo.Route, cred string) int {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
req := httptest.NewRequest(r.Method, authModesConcretize(r.Path), strings.NewReader("{}")).WithContext(ctx)
req.Header.Set("Content-Type", "application/json")
if c, ok := s.creds[cred]; ok && c.header != nil {
c.header(req)
}
rec := httptest.NewRecorder()
if strings.HasSuffix(r.Path, "/events") {
cancel()
}
s.e.ServeHTTP(rec, req)
return rec.Code
}
func newAuthModesServer(ownership string, opts ...config.AppOption) (*application.Application, *authModesServer) {
dir, err := filepath.EvalSymlinks(GinkgoT().TempDir())
Expect(err).NotTo(HaveOccurred())
models, backends := filepath.Join(dir, "models"), filepath.Join(dir, "backends")
Expect(os.Mkdir(models, 0o750)).To(Succeed())
Expect(os.Mkdir(backends, 0o750)).To(Succeed())
state, err := system.GetSystemState(system.WithModelPath(models), system.WithBackendPath(backends))
Expect(err).NotTo(HaveOccurred())
ctx, cancel := context.WithCancel(context.Background())
DeferCleanup(cancel)
root := filepath.Join(dir, "addons")
transport, address, err := gateway.NewLoopbackCarrierConfig("local", root)
Expect(err).NotTo(HaveOccurred())
all := append([]config.AppOption{
config.WithContext(ctx),
config.WithSystemState(state),
config.WithAddons(config.AddonConfig{
Enabled: true,
InstanceID: "local", OwnerID: "local", OwnerAddress: transport.OwnerAddress,
StorageID: "local", Root: root, ListenAddress: address, Transport: transport,
OwnershipMode: ownership,
Host: contract.HostCompatibility{Host: 1, API: contract.HostAPIVersion, Protocol: 1, SDK: 1, DataFormat: 1, OS: goruntime.GOOS, Arch: goruntime.GOARCH},
}),
}, opts...)
app, err := application.New(all...)
Expect(err).NotTo(HaveOccurred())
DeferCleanup(func() { _ = app.Shutdown() })
e, err := API(app)
Expect(err).NotTo(HaveOccurred())
return app, &authModesServer{e: e, creds: map[string]authModesCredential{}}
}
// adminOnlyAddonRoutes are the add-on routes a user without the administrator
// role never reaches, whatever the credential. The list restates the contract
// on purpose: a route that loses its administrator check drops out of the 403
// below instead of being followed silently.
var adminOnlyAddonRoutes = []string{
"GET /api/addons/apps",
"GET /api/addons/:id/recovery",
"POST /api/addons/apps/:id/start",
"POST /api/addons/apps/:id/stop",
"POST /api/addons/:id/upgrade",
"POST /api/addons/:id/purge",
"POST /api/addons/:id/update/check",
"POST /api/addons/:id/recovery/backup",
"POST /api/addons/:id/recovery/restore",
"POST /api/addons/:id/recovery/verify",
"POST /api/addons/:id/recovery/prune",
"POST /api/addons/:id/recovery/delete-backup",
}
// A route's answer in each mode. The expectations below do not depend on what
// the handler answers once it is reached (400, 404 and 409 all mean "reached");
// they pin who is let in.
func isDenied(code int) bool { return code == http.StatusUnauthorized || code == http.StatusForbidden }
// The new chats and add-on routes take their caller from the authentication
// middleware. This runs every one of them in each way a deployment can be
// configured, with the real server and its real middleware chain: no
// authentication (local owner), shared API keys, and accounts with sessions
// and per-user API keys.
var _ = Describe("Route auth modes", func() {
BeforeEach(func() {
if goruntime.GOOS != "linux" {
Skip("the add-on runtime exists on Linux only")
}
})
It("registers the routes the matrix covers", func() {
_, s := newAuthModesServer("local-owner")
paths := map[string]bool{}
for _, r := range s.routesUnderTest() {
paths[r.Method+" "+r.Path] = true
}
Expect(len(paths)).To(BeNumerically(">", 100))
for _, want := range append([]string{"GET /api/chats", "POST /api/chats", "GET /api/addons/builtin"}, adminOnlyAddonRoutes...) {
Expect(paths).To(HaveKey(want))
}
})
It("lets the local owner reach every route and refuses nothing at the door", func() {
_, s := newAuthModesServer("local-owner")
denied := []string{}
for _, r := range s.routesUnderTest() {
if code := s.status(r, ""); code == http.StatusUnauthorized {
denied = append(denied, r.Method+" "+r.Path)
}
}
Expect(denied).To(BeEmpty(), "routes that ask the local owner to sign in")
})
It("refuses an anonymous caller on every route when shared API keys are configured, and accepts the key", func() {
_, s := newAuthModesServer("local-owner", config.WithApiKeys([]string{authModesLegacyKey}))
s.creds["key"] = authModesCredential{header: func(r *http.Request) { r.Header.Set("Authorization", "Bearer "+authModesLegacyKey) }}
s.creds["wrong"] = authModesCredential{header: func(r *http.Request) { r.Header.Set("Authorization", "Bearer not-the-key") }}
problems := []string{}
for _, r := range s.routesUnderTest() {
name := r.Method + " " + r.Path
if code := s.status(r, ""); code != http.StatusUnauthorized {
problems = append(problems, name+": anonymous got "+http.StatusText(code))
}
if code := s.status(r, "wrong"); code != http.StatusUnauthorized {
problems = append(problems, name+": a wrong key got "+http.StatusText(code))
}
if code := s.status(r, "key"); code == http.StatusUnauthorized {
problems = append(problems, name+": the key was refused")
}
}
Expect(problems).To(BeEmpty())
})
It("treats the shared API key as the local owner on the add-on routes", func() {
_, local := newAuthModesServer("local-owner")
_, keyed := newAuthModesServer("local-owner", config.WithApiKeys([]string{authModesLegacyKey}))
keyed.creds["key"] = authModesCredential{header: func(r *http.Request) { r.Header.Set("Authorization", "Bearer "+authModesLegacyKey) }}
problems := []string{}
for _, r := range local.routesUnderTest() {
if !strings.HasPrefix(r.Path, "/api/addons") {
continue
}
if a, b := local.status(r, ""), keyed.status(r, "key"); a != b {
problems = append(problems, fmt.Sprintf("%s %s: local owner %d, shared key %d", r.Method, r.Path, a, b))
}
}
Expect(problems).To(BeEmpty())
})
Context("with accounts", func() {
var server *authModesServer
BeforeEach(func() {
app, s := newAuthModesServer("named-user",
config.WithAuthEnabled(true), config.WithAuthDatabaseURL(":memory:"), config.WithAuthAPIKeyHMACSecret(authModesHMACSecret))
db := app.AuthDB()
Expect(db).NotTo(BeNil())
for _, id := range []string{"alice", "carol", "root"} {
role := auth.RoleUser
if id == "root" {
role = auth.RoleAdmin
}
Expect(db.Create(&auth.User{ID: id, Email: id + "@example.test", Name: id, Role: role, Status: auth.StatusActive, Provider: auth.ProviderLocal}).Error).To(Succeed())
session, err := auth.CreateSession(db, id, authModesHMACSecret)
Expect(err).NotTo(HaveOccurred())
s.creds[id] = authModesCredential{header: func(r *http.Request) {
r.AddCookie(&http.Cookie{Name: "session", Value: session})
// The browser protection of the add-on routes wants a same-origin fetch.
r.Header.Set("Sec-Fetch-Site", "same-origin")
}}
plain, _, err := auth.CreateAPIKey(db, id, "test", role, authModesHMACSecret, nil)
Expect(err).NotTo(HaveOccurred())
s.creds[id+"-key"] = authModesCredential{header: func(r *http.Request) { r.Header.Set("Authorization", "Bearer "+plain) }}
}
// carol had the default-on chats feature revoked by an administrator.
Expect(auth.UpdateUserPermissions(db, "carol", auth.PermissionMap{auth.FeatureChats: false})).To(Succeed())
// agents, triggers, connectors and pages are off by default for a user; alice has them.
Expect(auth.UpdateUserPermissions(db, "alice", auth.PermissionMap{
auth.FeatureAgents: true, auth.FeatureTriggers: true, auth.FeatureConnectors: true, auth.FeaturePages: true,
})).To(Succeed())
server = s
})
It("refuses an anonymous caller on every route", func() {
problems := []string{}
for _, r := range server.routesUnderTest() {
if code := server.status(r, ""); code != http.StatusUnauthorized {
problems = append(problems, r.Method+" "+r.Path+": got "+http.StatusText(code))
}
}
Expect(problems).To(BeEmpty())
})
It("lets a signed-in user reach every chats route with a session and with an API key, and answers both the same way", func() {
problems := []string{}
count := 0
for _, r := range server.routesUnderTest() {
if !isChatsRoute(r.Path) {
continue
}
count++
name := r.Method + " " + r.Path
users := []string{"alice", "root"}
if strings.HasPrefix(r.Path, "/api/chats/admin/") {
// The administrator routes are closed to a user who is not one.
for _, cred := range []string{"alice", "alice-key"} {
if code := server.status(r, cred); code != http.StatusForbidden {
problems = append(problems, fmt.Sprintf("%s: %s got %d, not 403", name, cred, code))
}
}
users = []string{"root"}
}
for _, user := range users {
session, key := server.status(r, user), server.status(r, user+"-key")
if isDenied(session) || isDenied(key) {
problems = append(problems, fmt.Sprintf("%s: %s got %d with a session and %d with a key", name, user, session, key))
}
}
}
Expect(count).To(BeNumerically(">", 50))
Expect(problems).To(BeEmpty())
})
It("closes every chats route to a user whose chats feature was revoked", func() {
problems := []string{}
for _, r := range server.routesUnderTest() {
if !isChatsRoute(r.Path) {
continue
}
for _, cred := range []string{"carol", "carol-key"} {
if code := server.status(r, cred); code != http.StatusForbidden {
problems = append(problems, fmt.Sprintf("%s %s: %s got %d", r.Method, r.Path, cred, code))
}
}
}
Expect(problems).To(BeEmpty())
})
It("answers a session and an API key of the same user alike on every add-on route", func() {
problems := []string{}
for _, r := range server.routesUnderTest() {
if !strings.HasPrefix(r.Path, "/api/addons") {
continue
}
for _, user := range []string{"alice", "root"} {
if session, key := server.status(r, user), server.status(r, user+"-key"); session != key {
problems = append(problems, fmt.Sprintf("%s %s: %s got %d with a session and %d with a key", r.Method, r.Path, user, session, key))
}
}
}
Expect(problems).To(BeEmpty())
})
It("keeps the administrator routes of the add-ons from a user who is not an administrator", func() {
byName := map[string]*echo.Route{}
for _, r := range server.routesUnderTest() {
byName[r.Method+" "+r.Path] = r
}
problems := []string{}
for _, name := range adminOnlyAddonRoutes {
r, ok := byName[name]
Expect(ok).To(BeTrue(), name)
for _, cred := range []string{"alice", "alice-key", "carol"} {
if code := server.status(r, cred); code != http.StatusForbidden {
problems = append(problems, fmt.Sprintf("%s: %s got %d", name, cred, code))
}
}
for _, cred := range []string{"root", "root-key"} {
if code := server.status(r, cred); isDenied(code) {
problems = append(problems, fmt.Sprintf("%s: %s got %d", name, cred, code))
}
}
}
Expect(problems).To(BeEmpty())
})
It("lets every user read the add-on pages that every user sees", func() {
for _, path := range []string{"/api/addons/builtin", "/api/addons/pins"} {
r := &echo.Route{Method: http.MethodGet, Path: path}
for _, cred := range []string{"alice", "alice-key", "carol", "root"} {
Expect(server.status(r, cred)).To(Equal(http.StatusOK), path+" for "+cred)
}
}
})
})
})