mirror of
https://github.com/mudler/LocalAI.git
synced 2026-11-06 04:23:15 -05:00
feat(addons): add the /api/addons/host-grants routes
Seven routes: read the host access state, preview an install, install or upgrade with an approval, edit or revoke the grants of an add-on, change the blanket approval and change the deny rules (with a dry run). Reads take an administrator session or API key. Every write takes only the administrator's own browser session: same-origin fetch and the confirm header equal to the operation ID. An API key, bearer token, MCP session or tool call gets 403 not_human_surface. The dry run of a deny change is a read. The first path segment host-grants is reserved as an add-on ID, and the routes are listed in the feature registry. Assisted-by: Claude:claude-sonnet-5-5 go gofmt ginkgo
This commit is contained in:
8 files changed
+641
-2
No files matched your search
@@ -235,6 +235,13 @@ var RouteFeatureRegistry = []RouteFeature{
|
||||
{"GET", "/api/addons/:id/update", FeatureAddons},
|
||||
{"POST", "/api/addons/:id/update/check", FeatureAddons},
|
||||
{"POST", "/api/addons/:id/upgrade", FeatureAddons},
|
||||
{"GET", "/api/addons/host-grants", FeatureAddons},
|
||||
{"POST", "/api/addons/host-grants/preview", FeatureAddons},
|
||||
{"POST", "/api/addons/host-grants/install", FeatureAddons},
|
||||
{"PUT", "/api/addons/host-grants/blanket", FeatureAddons},
|
||||
{"PUT", "/api/addons/host-grants/deny", FeatureAddons},
|
||||
{"PUT", "/api/addons/host-grants/:addonId", FeatureAddons},
|
||||
{"DELETE", "/api/addons/host-grants/:addonId", FeatureAddons},
|
||||
// Chat / Completions
|
||||
{"POST", "/v1/chat/completions", FeatureChat},
|
||||
{"POST", "/chat/completions", FeatureChat},
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
package addons
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strconv"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/mudler/LocalAI/core/services/addons/gateway"
|
||||
"github.com/mudler/LocalAI/pkg/addons/contract"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// The host access routes are the UI-only half of the host operation policy:
|
||||
// what each add-on may ask the host to do, the deny rules and the blanket
|
||||
// approval. They are never tools. No MCP tool, model tool or proposal maps to
|
||||
// them. Reads take an administrator's session or API key. Every write takes
|
||||
// only the administrator's own browser session (same-origin fetch, the
|
||||
// X-LocalAI-Confirm header equal to the operation ID); an API key, a bearer
|
||||
// token, an MCP session or a tool call is refused, whatever its role. In a
|
||||
// split deployment the frontend decides that and forwards the decision to the
|
||||
// owner as a signed field; only the owner enforces and stores.
|
||||
|
||||
// hostGrantsAdmin runs the authentication and the administrator check, which
|
||||
// come before the body is read.
|
||||
func hostGrantsAdmin(c echo.Context, db *gorm.DB, s settings) (contract.Actor, error, bool) {
|
||||
privateHeaders(c)
|
||||
a, e := actor(c, db, s)
|
||||
if e != nil {
|
||||
return a, denied(c, e), false
|
||||
}
|
||||
if e = administrator(c, db, a, s); e != nil {
|
||||
if errors.Is(e, gateway.ErrUnavailable) {
|
||||
return a, failure(c, 503, "Addons unavailable"), false
|
||||
}
|
||||
return a, failure(c, 403, "Addon administration required"), false
|
||||
}
|
||||
return a, nil, true
|
||||
}
|
||||
|
||||
// hostGrantsFailure writes the answer for an error of a host access call.
|
||||
func hostGrantsFailure(c echo.Context, e error) error {
|
||||
var he *gateway.HostGrantsError
|
||||
if errors.As(e, &he) {
|
||||
return c.JSON(he.Status, AddonErrorResponse{Error: AddonError{Type: he.Type, Code: he.Status, Message: he.Message}})
|
||||
}
|
||||
var ue *gateway.UnavailableError
|
||||
if errors.As(e, &ue) {
|
||||
return failure(c, 503, ue.Reason)
|
||||
}
|
||||
return resultError(c, e)
|
||||
}
|
||||
|
||||
// hostGrantsDo checks the human surface of a write, runs the call and writes
|
||||
// the data of the reply.
|
||||
func hostGrantsDo(c echo.Context, g *gateway.Gateway, s settings, a contract.Actor, call gateway.HostGrantsCall) error {
|
||||
ctx := c.Request().Context()
|
||||
if call.OperationID != "" {
|
||||
if !humanSurface(c, s, a, call.OperationID) {
|
||||
return c.JSON(403, AddonErrorResponse{Error: AddonError{Type: gateway.HostGrantsNotHumanSurface, Code: 403, Message: "This action requires your own browser session"}})
|
||||
}
|
||||
ctx = gateway.WithHumanSurface(ctx)
|
||||
}
|
||||
if g == nil {
|
||||
return failure(c, 503, "Addons unavailable")
|
||||
}
|
||||
raw, e := g.HostGrants(ctx, a, call)
|
||||
if e != nil {
|
||||
return hostGrantsFailure(c, e)
|
||||
}
|
||||
return c.Blob(200, echo.MIMEApplicationJSON, raw)
|
||||
}
|
||||
|
||||
func validOperationID(id string) bool { return id != "" && len(id) <= 128 }
|
||||
|
||||
// hostGrantsBody is the shared front of a route with a JSON body: administrator
|
||||
// check, strict decode, operation ID, call.
|
||||
func hostGrantsBody[T any](g *gateway.Gateway, db *gorm.DB, opts []Option, op string, opOf func(*T) string, addon func(echo.Context) string, prepare func(*T) string) echo.HandlerFunc {
|
||||
s := resolve(opts)
|
||||
return func(c echo.Context) error {
|
||||
a, err, ok := hostGrantsAdmin(c, db, s)
|
||||
if !ok {
|
||||
return err
|
||||
}
|
||||
var req T
|
||||
if decode(c, &req) != nil {
|
||||
return failure(c, 400, "Invalid host access request")
|
||||
}
|
||||
callOp := op
|
||||
if prepare != nil {
|
||||
callOp = prepare(&req)
|
||||
}
|
||||
id := opOf(&req)
|
||||
write := callOp != gateway.HostGrantsPreview && callOp != gateway.HostGrantsDenyPreview
|
||||
if write && !validOperationID(id) {
|
||||
return failure(c, 400, "An operationId is required")
|
||||
}
|
||||
if !write && id != "" {
|
||||
// A read has no operation of its own; the field is not accepted.
|
||||
return failure(c, 400, "Invalid host access request")
|
||||
}
|
||||
body, e := json.Marshal(req)
|
||||
if e != nil {
|
||||
return failure(c, 400, "Invalid host access request")
|
||||
}
|
||||
call := gateway.HostGrantsCall{Op: callOp, Body: body}
|
||||
if write {
|
||||
call.OperationID = id
|
||||
}
|
||||
if addon != nil {
|
||||
call.AddonID = addon(c)
|
||||
}
|
||||
noteRequest(c, contract.Request{Action: "hostgrants-" + callOp, AddonID: call.AddonID})
|
||||
return hostGrantsDo(c, g, s, a, call)
|
||||
}
|
||||
}
|
||||
|
||||
// GetHostGrants reads the host access state.
|
||||
// @Summary Read the host access policy and what each add-on holds (administrators)
|
||||
// @Description Administrators only (an administrator's session or API key). Returns the revision of the grants document, whether the deployment locks the UI store, the blanket approval switch (mode off, official or any; setBy ui or deployment), the deployment floor (the policy file and flags, read-only), the deny rules by source (file and flags, which cannot be edited here, and ui), and for each installed add-on the operations its verified manifest requests (required and optional) with a plain title, text and risk, whether each is allowed, not_allowed, blocked or unsupported, where the grant comes from (deployment, install, upgrade, edit or blanket), when and by whom, the operations granted that the manifest no longer requests, an identity mismatch flag and a pending upgrade that waits for approval. Read-only. Nothing in the answer is a credential. No MCP tool maps to this route.
|
||||
// @Tags Addons
|
||||
// @Produce json
|
||||
// @Success 200 {object} gateway.HostGrantsView "Host access state"
|
||||
// @Failure 401 {object} AddonErrorResponse "Named authentication required"
|
||||
// @Failure 403 {object} AddonErrorResponse "Feature or administration denied"
|
||||
// @Failure 503 {object} AddonErrorResponse "Addons or the owner are unavailable"
|
||||
// @Router /api/addons/host-grants [get]
|
||||
func GetHostGrants(g *gateway.Gateway, db *gorm.DB, opts ...Option) echo.HandlerFunc {
|
||||
s := resolve(opts)
|
||||
return func(c echo.Context) error {
|
||||
a, err, ok := hostGrantsAdmin(c, db, s)
|
||||
if !ok {
|
||||
return err
|
||||
}
|
||||
return hostGrantsDo(c, g, s, a, gateway.HostGrantsCall{Op: gateway.HostGrantsRead})
|
||||
}
|
||||
}
|
||||
|
||||
// PreviewHostGrants previews what an install would ask for.
|
||||
// @Summary Preview the host access an install or upgrade would ask for (administrators)
|
||||
// @Description Administrators only (an administrator's session or API key). The owner resolves and stages the release, reads the verified manifest of the staged package, computes the answer against the policy and discards the stage. Returns the digest of the staged package, the requested operations with plain text and risk, and for each one held (already allowed), grantable, blocked (a deny rule, which wins over every grant) or unsupported; canInstall false with blockedRequired when a required operation is blocked; whether the blanket approval would grant on install; the revision of the grants document; and a single-use nonce (five minutes, bound to the caller's credential, the add-on, the digest and the revision) that the install must present. Operations granted before an earlier uninstall carry hinted true so the dialog may tick them; a hint grants nothing. Changes no state.
|
||||
// @Tags Addons
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body gateway.HostGrantsPreviewRequest true "Release to preview"
|
||||
// @Success 200 {object} gateway.HostGrantsPreviewView "What the release asks for"
|
||||
// @Failure 400 {object} AddonErrorResponse "Malformed or unknown request fields"
|
||||
// @Failure 401 {object} AddonErrorResponse "Named authentication required"
|
||||
// @Failure 403 {object} AddonErrorResponse "Feature or administration denied, or the release could not be verified"
|
||||
// @Failure 503 {object} AddonErrorResponse "Addons or the owner are unavailable"
|
||||
// @Router /api/addons/host-grants/preview [post]
|
||||
func PreviewHostGrants(g *gateway.Gateway, db *gorm.DB, opts ...Option) echo.HandlerFunc {
|
||||
return hostGrantsBody(g, db, opts, gateway.HostGrantsPreview, func(*gateway.HostGrantsPreviewRequest) string { return "" }, nil, nil)
|
||||
}
|
||||
|
||||
// InstallWithHostGrants installs or upgrades with an approval.
|
||||
// @Summary Install or upgrade an add-on with the host access the administrator approved (browser session)
|
||||
// @Description Administrators only, from their own browser session (same-origin fetch, X-LocalAI-Confirm equal to the operationId). API keys, bearer tokens, MCP sessions and tool calls are refused with 403 not_human_surface. Carries the approval the dialog collected: the digest of the previewed package, the operations ticked, the revision of the grants document and the preview nonce. The owner stages the package again and grants only the operations that are approved AND declared by that verified manifest AND registered by the host AND not denied; a different package is 409 approval_stale, a changed document 409 revision_conflict, an expired or reused nonce 409 approval_stale. A required operation that is neither held nor approved is refused (approval_required) and one that a deny rule blocks is 403 blocked_by_policy; nothing is written then. For action upgrade, expectedDigest and candidateDigest pin the upgrade as in the plain upgrade. The plain dispatch install carries no approval. Repeating the operationId with the same request returns the recorded operation. After outcome_unknown keep the operationId and poll the operation status.
|
||||
// @Tags Addons
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body gateway.HostGrantsInstallRequest true "Install with approval"
|
||||
// @Success 200 {object} addons.OperationStatus "The lifecycle operation, with the host operations summary"
|
||||
// @Failure 400 {object} AddonErrorResponse "Malformed request"
|
||||
// @Failure 401 {object} AddonErrorResponse "Named authentication required"
|
||||
// @Failure 403 {object} AddonErrorResponse "Not a human surface (not_human_surface), locked_by_deployment, blocked_by_policy, approval_required, or administration denied"
|
||||
// @Failure 409 {object} AddonErrorResponse "revision_conflict or approval_stale"
|
||||
// @Failure 503 {object} AddonErrorResponse "Unavailable, or outcome_unknown (with operationId)"
|
||||
// @Router /api/addons/host-grants/install [post]
|
||||
func InstallWithHostGrants(g *gateway.Gateway, db *gorm.DB, opts ...Option) echo.HandlerFunc {
|
||||
return hostGrantsBody(g, db, opts, gateway.HostGrantsInstall, func(r *gateway.HostGrantsInstallRequest) string { return r.OperationID }, nil, nil)
|
||||
}
|
||||
|
||||
// EditHostGrants replaces the UI grants of an add-on.
|
||||
// @Summary Set the host operations an add-on keeps through the UI (browser session)
|
||||
// @Description Administrators only, from their own browser session (X-LocalAI-Confirm equal to the operationId). ops is the full set the add-on keeps: operations left out are revoked, new ones must be declared by the installed manifest, registered by the host and not denied (403 blocked_by_policy). Grants made by the policy file or flags are not editable here. keepBlanketAsOwn relabels the blanket grants as the administrator's own. revision is the revision the caller read; a stale one is 409 revision_conflict. Takes effect for the next call of the add-on with no restart.
|
||||
// @Tags Addons
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param addonId path string true "Add-on ID"
|
||||
// @Param request body gateway.HostGrantsEditRequest true "Edit"
|
||||
// @Success 200 {object} gateway.HostGrantsAddonResult "The add-on entry and the new revision"
|
||||
// @Failure 400 {object} AddonErrorResponse "Malformed request"
|
||||
// @Failure 401 {object} AddonErrorResponse "Named authentication required"
|
||||
// @Failure 403 {object} AddonErrorResponse "not_human_surface, locked_by_deployment, blocked_by_policy or administration denied"
|
||||
// @Failure 404 {object} AddonErrorResponse "The add-on is not installed"
|
||||
// @Failure 409 {object} AddonErrorResponse "revision_conflict or operation_conflict"
|
||||
// @Failure 503 {object} AddonErrorResponse "Unavailable, or outcome_unknown (with operationId)"
|
||||
// @Router /api/addons/host-grants/{addonId} [put]
|
||||
func EditHostGrants(g *gateway.Gateway, db *gorm.DB, opts ...Option) echo.HandlerFunc {
|
||||
return hostGrantsBody(g, db, opts, gateway.HostGrantsEdit, func(r *gateway.HostGrantsEditRequest) string { return r.OperationID }, func(c echo.Context) string { return c.Param("addonId") }, nil)
|
||||
}
|
||||
|
||||
// RevokeHostGrants removes the UI grants of an add-on.
|
||||
// @Summary Revoke every host operation granted to an add-on through the UI (browser session)
|
||||
// @Description Administrators only, from their own browser session (X-LocalAI-Confirm equal to the operationId). Removes the UI and blanket grants of the add-on; grants of the policy file stay. Query: revision (the revision the caller read) and operationId. Takes effect for the next call with no restart; a running call finishes. An add-on whose required operation is revoked becomes unavailable.
|
||||
// @Tags Addons
|
||||
// @Produce json
|
||||
// @Param addonId path string true "Add-on ID"
|
||||
// @Param revision query integer true "Revision of the grants document the caller read"
|
||||
// @Param operationId query string true "Operation ID"
|
||||
// @Success 200 {object} gateway.HostGrantsRevision "The new revision"
|
||||
// @Failure 400 {object} AddonErrorResponse "Malformed request"
|
||||
// @Failure 401 {object} AddonErrorResponse "Named authentication required"
|
||||
// @Failure 403 {object} AddonErrorResponse "not_human_surface, locked_by_deployment or administration denied"
|
||||
// @Failure 404 {object} AddonErrorResponse "The add-on is not installed"
|
||||
// @Failure 409 {object} AddonErrorResponse "revision_conflict or operation_conflict"
|
||||
// @Failure 503 {object} AddonErrorResponse "Unavailable, or outcome_unknown (with operationId)"
|
||||
// @Router /api/addons/host-grants/{addonId} [delete]
|
||||
func RevokeHostGrants(g *gateway.Gateway, db *gorm.DB, opts ...Option) echo.HandlerFunc {
|
||||
s := resolve(opts)
|
||||
return func(c echo.Context) error {
|
||||
a, err, ok := hostGrantsAdmin(c, db, s)
|
||||
if !ok {
|
||||
return err
|
||||
}
|
||||
rev, perr := strconv.ParseUint(c.QueryParam("revision"), 10, 64)
|
||||
id := c.QueryParam("operationId")
|
||||
if perr != nil || !validOperationID(id) {
|
||||
return failure(c, 400, "revision and operationId are required")
|
||||
}
|
||||
body, _ := json.Marshal(gateway.HostGrantsRevokeRequest{OperationID: id, Revision: rev})
|
||||
call := gateway.HostGrantsCall{Op: gateway.HostGrantsRevoke, OperationID: id, AddonID: c.Param("addonId"), Body: body}
|
||||
noteRequest(c, contract.Request{Action: "hostgrants-revoke", AddonID: call.AddonID})
|
||||
return hostGrantsDo(c, g, s, a, call)
|
||||
}
|
||||
}
|
||||
|
||||
// SetHostGrantsBlanket changes the blanket approval.
|
||||
// @Summary Turn the blanket approval on, off or wider (browser session, typed confirmation)
|
||||
// @Description Administrators only, from their own browser session (X-LocalAI-Confirm equal to the operationId). Mode off, official or any. With it on, an install or upgrade grants the add-on the operations its verified manifest declares as required (also the optional ones with includeOptional) without the dialog step; it never grants an undeclared or unregistered operation and never overrides a deny rule. Mode official covers only the official library with its official signer. Mode any covers every library the administrator configured: ANY LIBRARY ENTRY THEN RECEIVES EXACTLY THE OPERATIONS ITS MANIFEST DECLARES, with no review, so a malicious or compromised entry gets what it asks for. Turning it on, or widening it, needs confirm equal to "grant official add-ons what they declare" (official) or "grant any library add-on what it declares" (any), else 400 confirmation_mismatch. applyToInstalled also grants the installed add-ons in the same revision. Turning it off keeps the grants it made, marked blanket; revokeOnOff revokes them all. When the deployment fixes the mode the answer is 403 locked_by_deployment.
|
||||
// @Tags Addons
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body gateway.HostGrantsBlanketRequest true "Blanket change"
|
||||
// @Success 200 {object} gateway.HostGrantsBlanketResult "The switch, the add-ons that were granted or skipped and the add-ons whose blanket grants were revoked"
|
||||
// @Failure 400 {object} AddonErrorResponse "Malformed request, or confirmation_mismatch"
|
||||
// @Failure 401 {object} AddonErrorResponse "Named authentication required"
|
||||
// @Failure 403 {object} AddonErrorResponse "not_human_surface, locked_by_deployment or administration denied"
|
||||
// @Failure 409 {object} AddonErrorResponse "revision_conflict or operation_conflict"
|
||||
// @Failure 503 {object} AddonErrorResponse "Unavailable, or outcome_unknown (with operationId)"
|
||||
// @Router /api/addons/host-grants/blanket [put]
|
||||
func SetHostGrantsBlanket(g *gateway.Gateway, db *gorm.DB, opts ...Option) echo.HandlerFunc {
|
||||
return hostGrantsBody(g, db, opts, gateway.HostGrantsBlanket, func(r *gateway.HostGrantsBlanketRequest) string { return r.OperationID }, nil, nil)
|
||||
}
|
||||
|
||||
// SetHostGrantsDeny replaces the UI deny rules.
|
||||
// @Summary Replace the deny rules set in the UI, or preview their effect (browser session)
|
||||
// @Description Administrators only. A deny rule beats every grant. deny maps an add-on ID, or "*" for all, to prefixes ("egress."), exact operation names or the single token "*". The rules of the policy file and flags are not editable here and are unioned with these. With dryRun true the answer is the affected add-ons only (affects: the operations each would lose and whether it will stop because a required one is lost) and nothing changes; a dry run needs the administrator but not the browser session. Without dryRun the change needs the administrator's own browser session (X-LocalAI-Confirm equal to the operationId), the revision the caller read (409 revision_conflict otherwise), and applies to the next call with no restart.
|
||||
// @Tags Addons
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body gateway.HostGrantsDenyRequest true "Deny rules"
|
||||
// @Success 200 {object} gateway.HostGrantsDenyResult "The rules and what they take"
|
||||
// @Failure 400 {object} AddonErrorResponse "Malformed request or invalid rule"
|
||||
// @Failure 401 {object} AddonErrorResponse "Named authentication required"
|
||||
// @Failure 403 {object} AddonErrorResponse "not_human_surface, locked_by_deployment or administration denied"
|
||||
// @Failure 409 {object} AddonErrorResponse "revision_conflict or operation_conflict"
|
||||
// @Failure 503 {object} AddonErrorResponse "Unavailable, or outcome_unknown (with operationId)"
|
||||
// @Router /api/addons/host-grants/deny [put]
|
||||
func SetHostGrantsDeny(g *gateway.Gateway, db *gorm.DB, opts ...Option) echo.HandlerFunc {
|
||||
return hostGrantsBody(g, db, opts, gateway.HostGrantsDeny, func(r *gateway.HostGrantsDenyRequest) string { return r.OperationID }, nil,
|
||||
func(r *gateway.HostGrantsDenyRequest) string {
|
||||
if r.DryRun {
|
||||
// A dry run is a read and has no operation of its own.
|
||||
r.OperationID = ""
|
||||
return gateway.HostGrantsDenyPreview
|
||||
}
|
||||
return gateway.HostGrantsDeny
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
//go:build auth
|
||||
|
||||
package addons
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/mudler/LocalAI/core/config"
|
||||
"github.com/mudler/LocalAI/core/http/auth"
|
||||
"github.com/mudler/LocalAI/core/services/addons/gateway"
|
||||
"github.com/mudler/LocalAI/core/services/addons/hostgrants"
|
||||
"github.com/mudler/LocalAI/pkg/addons/contract"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
// These specs use real accounts: the credential type decides.
|
||||
var _ = Describe("host access routes with real sessions and API keys", func() {
|
||||
It("lets an administrator's session write and refuses the same administrator's API key", func() {
|
||||
db, err := auth.InitDB(filepath.Join(GinkgoT().TempDir(), "auth.db"))
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
admin := auth.User{ID: "admin", Role: auth.RoleAdmin, Status: auth.StatusActive}
|
||||
plain := auth.User{ID: "plain", Role: auth.RoleUser, Status: auth.StatusActive}
|
||||
Expect(db.Create(&admin).Error).To(Succeed())
|
||||
Expect(db.Create(&plain).Error).To(Succeed())
|
||||
adminKey, _, err := auth.CreateAPIKey(db, admin.ID, "test", auth.RoleAdmin, "secret", nil)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
adminSession, err := auth.CreateSession(db, admin.ID, "secret")
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
plainSession, err := auth.CreateSession(db, plain.ID, "secret")
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
store, err := hostgrants.Open(filepath.Join(GinkgoT().TempDir(), "hostgrants.json"))
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
DeferCleanup(store.Close)
|
||||
svc, err := gateway.NewHostGrantsOwner(gateway.HostGrantsConfig{Store: store, Manager: &hgManager{},
|
||||
Admin: func(ctx context.Context, a contract.Actor) error { return auth.ValidateAddonActor(ctx, db, a, true) }})
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
gw := &gateway.Gateway{HostGrantsOwner: svc, Revalidate: func(ctx context.Context, a contract.Actor) error { return auth.ValidateAddonActor(ctx, db, a, false) }}
|
||||
|
||||
e := echo.New()
|
||||
cfg := &config.ApplicationConfig{}
|
||||
cfg.Auth.APIKeyHMACSecret = "secret"
|
||||
e.Use(auth.Middleware(db, cfg))
|
||||
g := e.Group("/api/addons", BrowserProtection())
|
||||
g.GET("/host-grants", GetHostGrants(gw, db))
|
||||
g.PUT("/host-grants/:addonId", EditHostGrants(gw, db))
|
||||
|
||||
put := func(mutate func(*http.Request)) int {
|
||||
r := httptest.NewRequest("PUT", "/api/addons/host-grants/sandbox", strings.NewReader(`{"operationId":"op1","revision":0,"ops":["egress.check"]}`))
|
||||
r.Host = "192.0.2.10:8080"
|
||||
r.Header.Set("Content-Type", "application/json")
|
||||
mutate(r)
|
||||
w := httptest.NewRecorder()
|
||||
e.ServeHTTP(w, r)
|
||||
return w.Code
|
||||
}
|
||||
tab := func(session string) func(*http.Request) {
|
||||
return func(r *http.Request) {
|
||||
r.AddCookie(&http.Cookie{Name: "session", Value: session})
|
||||
r.Header.Set("Sec-Fetch-Site", "same-origin")
|
||||
r.Header.Set("Sec-Fetch-Dest", "empty")
|
||||
r.Header.Set("Sec-Fetch-Mode", "cors")
|
||||
r.Header.Set(ConfirmHeader, "op1")
|
||||
}
|
||||
}
|
||||
// The session of an administrator passes every gate.
|
||||
Expect(put(tab(adminSession))).To(Equal(200))
|
||||
Expect(put(func(r *http.Request) {
|
||||
r.Header.Set("Authorization", "Bearer "+adminKey)
|
||||
r.Header.Set("Sec-Fetch-Site", "same-origin")
|
||||
r.Header.Set(ConfirmHeader, "op1")
|
||||
})).To(Equal(403), "an administrator API key")
|
||||
Expect(put(func(r *http.Request) {
|
||||
tab(adminSession)(r)
|
||||
r.Header.Set("Authorization", "Bearer "+adminKey)
|
||||
})).To(Equal(403), "a session cookie with a key header")
|
||||
Expect(put(func(r *http.Request) {
|
||||
tab(adminSession)(r)
|
||||
r.Header.Del(ConfirmHeader)
|
||||
})).To(Equal(403), "no confirm header")
|
||||
Expect(put(tab(plainSession))).To(BeElementOf(401, 403), "a session of a user who is not an administrator")
|
||||
|
||||
read := func(mutate func(*http.Request)) int {
|
||||
r := httptest.NewRequest("GET", "/api/addons/host-grants", nil)
|
||||
r.Host = "192.0.2.10:8080"
|
||||
mutate(r)
|
||||
w := httptest.NewRecorder()
|
||||
e.ServeHTTP(w, r)
|
||||
return w.Code
|
||||
}
|
||||
Expect(read(func(r *http.Request) { r.Header.Set("Authorization", "Bearer "+adminKey) })).To(Equal(200), "an administrator key may read")
|
||||
Expect(read(func(r *http.Request) { r.AddCookie(&http.Cookie{Name: "session", Value: plainSession}) })).To(BeElementOf(401, 403))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,237 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
package addons
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/mudler/LocalAI/core/http/auth"
|
||||
managerpkg "github.com/mudler/LocalAI/core/services/addons"
|
||||
"github.com/mudler/LocalAI/core/services/addons/gateway"
|
||||
"github.com/mudler/LocalAI/core/services/addons/hostgrants"
|
||||
"github.com/mudler/LocalAI/pkg/addons/contract"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
type hgManager struct {
|
||||
submitted []gateway.HostApproval
|
||||
}
|
||||
|
||||
func (m *hgManager) HostAccess(context.Context) ([]gateway.InstalledAccess, error) {
|
||||
return []gateway.InstalledAccess{{AddonID: "sandbox", Title: "Sandbox", Version: "1.0.0", State: "running", Identity: "id-sbx", Generation: 1, OperationID: "op-install",
|
||||
Required: []string{"workspace.materialize"}, Optional: []string{"egress.check"}}}, nil
|
||||
}
|
||||
|
||||
func (m *hgManager) PreviewHostAccess(context.Context, string, string, string) (gateway.ReleaseAccess, error) {
|
||||
return gateway.ReleaseAccess{AddonID: "newone", Title: "New one", Version: "1.0.0", Digest: "sha256:aa", Required: []string{"workspace.materialize"}}, nil
|
||||
}
|
||||
|
||||
func (m *hgManager) SubmitWithApproval(_ context.Context, _ contract.Actor, cmd managerpkg.LifecycleCommand, ap gateway.HostApproval) (managerpkg.OperationStatus, error) {
|
||||
m.submitted = append(m.submitted, ap)
|
||||
return managerpkg.OperationStatus{OperationID: cmd.OperationID, AddonID: cmd.AddonID, Phase: "completed"}, nil
|
||||
}
|
||||
|
||||
// registerHostGrants registers the routes as routes/addons.go does, over a
|
||||
// local owner (no accounts, so the synthetic local owner is the caller).
|
||||
func registerHostGrants(e *echo.Echo, gw *gateway.Gateway) {
|
||||
mode := WithOwnership(auth.AddonModeLocalOwner)
|
||||
g := e.Group("/api/addons", BrowserProtection())
|
||||
g.GET("/host-grants", GetHostGrants(gw, nil, mode))
|
||||
g.POST("/host-grants/preview", PreviewHostGrants(gw, nil, mode))
|
||||
g.POST("/host-grants/install", InstallWithHostGrants(gw, nil, mode))
|
||||
g.PUT("/host-grants/blanket", SetHostGrantsBlanket(gw, nil, mode))
|
||||
g.PUT("/host-grants/deny", SetHostGrantsDeny(gw, nil, mode))
|
||||
g.PUT("/host-grants/:addonId", EditHostGrants(gw, nil, mode))
|
||||
g.DELETE("/host-grants/:addonId", RevokeHostGrants(gw, nil, mode))
|
||||
}
|
||||
|
||||
var _ = Describe("host access routes", func() {
|
||||
const host = "192.0.2.10:8080"
|
||||
var (
|
||||
e *echo.Echo
|
||||
mgr *hgManager
|
||||
)
|
||||
BeforeEach(func() {
|
||||
store, err := hostgrants.Open(filepath.Join(GinkgoT().TempDir(), "hostgrants.json"))
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
DeferCleanup(store.Close)
|
||||
mgr = &hgManager{}
|
||||
svc, err := gateway.NewHostGrantsOwner(gateway.HostGrantsConfig{Store: store, Manager: mgr,
|
||||
Floor: hostgrants.Floor{Deny: map[string][]string{"*": {"connector."}}},
|
||||
Admin: func(context.Context, contract.Actor) error { return nil }})
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
e = echo.New()
|
||||
registerHostGrants(e, &gateway.Gateway{HostGrantsOwner: svc, Revalidate: func(context.Context, contract.Actor) error { return nil }})
|
||||
})
|
||||
|
||||
type req struct{ method, path, body, op string }
|
||||
// browser is the administrator's own tab: same-origin fetch, the confirm header.
|
||||
browser := func(r *http.Request, op string) {
|
||||
r.Header.Set("Sec-Fetch-Site", "same-origin")
|
||||
r.Header.Set("Sec-Fetch-Dest", "empty")
|
||||
r.Header.Set("Sec-Fetch-Mode", "cors")
|
||||
if op != "" {
|
||||
r.Header.Set(ConfirmHeader, op)
|
||||
}
|
||||
}
|
||||
do := func(q req, mutate func(*http.Request)) (int, map[string]any) {
|
||||
var body io.Reader
|
||||
if q.body != "" {
|
||||
body = strings.NewReader(q.body)
|
||||
}
|
||||
r := httptest.NewRequest(q.method, q.path, body)
|
||||
r.Host = host
|
||||
r.Header.Set("Content-Type", "application/json")
|
||||
mutate(r)
|
||||
w := httptest.NewRecorder()
|
||||
e.ServeHTTP(w, r)
|
||||
var out map[string]any
|
||||
_ = json.Unmarshal(w.Body.Bytes(), &out)
|
||||
return w.Code, out
|
||||
}
|
||||
errType := func(out map[string]any) string {
|
||||
er, _ := out["error"].(map[string]any)
|
||||
s, _ := er["type"].(string)
|
||||
return s
|
||||
}
|
||||
asHuman := func(q req) (int, map[string]any) { return do(q, func(r *http.Request) { browser(r, q.op) }) }
|
||||
|
||||
edit := func(op string, rev int, ops string) req {
|
||||
return req{"PUT", "/api/addons/host-grants/sandbox", `{"operationId":"` + op + `","revision":` + itoa(rev) + `,"ops":` + ops + `}`, op}
|
||||
}
|
||||
|
||||
// The write routes, each with a body that decodes, for the guard table.
|
||||
writes := []req{
|
||||
edit("w1", 0, `["egress.check"]`),
|
||||
{"DELETE", "/api/addons/host-grants/sandbox?revision=0&operationId=w2", "", "w2"},
|
||||
{"PUT", "/api/addons/host-grants/blanket", `{"operationId":"w3","revision":0,"mode":"off"}`, "w3"},
|
||||
{"PUT", "/api/addons/host-grants/deny", `{"operationId":"w4","revision":0,"deny":{}}`, "w4"},
|
||||
{"POST", "/api/addons/host-grants/install", `{"operationId":"w5","action":"install","sourceId":"official","addonId":"newone","version":"1.0.0","approve":{"digest":"sha256:aa","ops":[],"revision":0,"nonce":"n"}}`, "w5"},
|
||||
}
|
||||
|
||||
It("serves the reads to an administrator by API key as well", func() {
|
||||
for _, q := range []req{{"GET", "/api/addons/host-grants", "", ""}, {"POST", "/api/addons/host-grants/preview", `{"sourceId":"official","addonId":"newone","version":"1.0.0"}`, ""},
|
||||
{"PUT", "/api/addons/host-grants/deny", `{"revision":0,"deny":{"sandbox":["workspace."]},"dryRun":true}`, ""}} {
|
||||
code, out := do(q, func(r *http.Request) {
|
||||
r.Header.Set("Authorization", "Bearer an-admin-key")
|
||||
r.Header.Set("Sec-Fetch-Site", "same-origin")
|
||||
})
|
||||
Expect(code).To(Equal(200), q.method+" "+q.path)
|
||||
Expect(out).NotTo(BeEmpty())
|
||||
}
|
||||
})
|
||||
|
||||
DescribeTable("refuses every write that is not the administrator's own tab, with 403 not_human_surface",
|
||||
func(name string, mutate func(*http.Request, string)) {
|
||||
for _, q := range writes {
|
||||
code, out := do(q, func(r *http.Request) { mutate(r, q.op) })
|
||||
Expect(code).To(Equal(403), name+" "+q.method+" "+q.path)
|
||||
Expect(errType(out)).To(Equal("not_human_surface"), name+" "+q.path)
|
||||
}
|
||||
},
|
||||
Entry("an API key", "key", func(r *http.Request, op string) { browser(r, op); r.Header.Set("Authorization", "Bearer k") }),
|
||||
Entry("an x-api-key header", "xkey", func(r *http.Request, op string) { browser(r, op); r.Header.Set("X-Api-Key", "k") }),
|
||||
Entry("the legacy token cookie", "token", func(r *http.Request, op string) { browser(r, op); r.AddCookie(&http.Cookie{Name: "token", Value: "k"}) }),
|
||||
Entry("an MCP session", "mcp", func(r *http.Request, op string) { browser(r, op); r.Header.Set("Mcp-Session-Id", "s") }),
|
||||
Entry("a tool call", "tool", func(r *http.Request, op string) { browser(r, op); r.Header.Set("X-LocalAI-Tool-Call", "1") }),
|
||||
Entry("an agent run", "agent", func(r *http.Request, op string) { browser(r, op); r.Header.Set("X-LocalAI-Agent", "1") }),
|
||||
Entry("a cross-site page", "cross", func(r *http.Request, op string) { browser(r, op); r.Header.Set("Sec-Fetch-Site", "same-site") }),
|
||||
Entry("a navigation", "nav", func(r *http.Request, op string) { browser(r, op); r.Header.Set("Sec-Fetch-Dest", "document") }),
|
||||
Entry("a missing confirm header", "noconfirm", func(r *http.Request, op string) { browser(r, "") }),
|
||||
Entry("a confirm header for another operation", "otherconfirm", func(r *http.Request, op string) { browser(r, "someone-else") }),
|
||||
Entry("a foreign Origin without fetch metadata", "origin", func(r *http.Request, op string) {
|
||||
r.Header.Set("Origin", "http://evil.example")
|
||||
r.Header.Set(ConfirmHeader, op)
|
||||
}),
|
||||
)
|
||||
|
||||
It("accepts a plain-HTTP tab that names this host in Origin when the browser sends no fetch metadata", func() {
|
||||
q := edit("o1", 0, `["egress.check"]`)
|
||||
code, _ := do(q, func(r *http.Request) { r.Header.Set("Origin", "http://"+host); r.Header.Set(ConfirmHeader, q.op) })
|
||||
Expect(code).To(Equal(200))
|
||||
})
|
||||
|
||||
It("edits, replays the same operation and refuses a stale revision with 409", func() {
|
||||
code, out := asHuman(edit("e1", 0, `["egress.check"]`))
|
||||
Expect(code).To(Equal(200))
|
||||
Expect(out["revision"]).To(BeEquivalentTo(1))
|
||||
code, out = asHuman(edit("e1", 0, `["egress.check"]`))
|
||||
Expect(code).To(Equal(200), "the same operation again")
|
||||
Expect(out["revision"]).To(BeEquivalentTo(1))
|
||||
code, out = asHuman(edit("e2", 0, `["egress.check"]`))
|
||||
Expect(code).To(Equal(409))
|
||||
Expect(errType(out)).To(Equal("revision_conflict"))
|
||||
code, out = asHuman(edit("e1", 1, `[]`))
|
||||
Expect(code).To(Equal(409))
|
||||
Expect(errType(out)).To(Equal("operation_conflict"))
|
||||
})
|
||||
|
||||
It("answers the typed refusals of a bad write", func() {
|
||||
code, out := asHuman(edit("e1", 0, `["connector.inbound"]`))
|
||||
Expect(code).To(Equal(400), "not declared by the manifest")
|
||||
Expect(errType(out)).To(Equal("invalid_request"))
|
||||
code, out = asHuman(req{"PUT", "/api/addons/host-grants/ghost", `{"operationId":"e2","revision":0,"ops":["egress.check"]}`, "e2"})
|
||||
Expect(code).To(Equal(404))
|
||||
Expect(errType(out)).To(Equal("not_found"))
|
||||
code, _ = asHuman(req{"PUT", "/api/addons/host-grants/sandbox", `{"operationId":"e3","revision":0,"ops":[],"surprise":1}`, "e3"})
|
||||
Expect(code).To(Equal(400), "an unknown field")
|
||||
code, _ = asHuman(req{"PUT", "/api/addons/host-grants/sandbox", `{"revision":0,"ops":[]}`, ""})
|
||||
Expect(code).To(Equal(400), "an operation ID is required")
|
||||
code, _ = asHuman(req{"DELETE", "/api/addons/host-grants/sandbox?operationId=r1", "", "r1"})
|
||||
Expect(code).To(Equal(400), "the revision is required")
|
||||
})
|
||||
|
||||
It("revokes with the revision in the query", func() {
|
||||
asHuman(edit("e1", 0, `["egress.check"]`))
|
||||
code, out := asHuman(req{"DELETE", "/api/addons/host-grants/sandbox?revision=0&operationId=r1", "", "r1"})
|
||||
Expect(code).To(Equal(409), "stale")
|
||||
Expect(errType(out)).To(Equal("revision_conflict"))
|
||||
code, out = asHuman(req{"DELETE", "/api/addons/host-grants/sandbox?revision=1&operationId=r2", "", "r2"})
|
||||
Expect(code).To(Equal(200))
|
||||
Expect(out["revision"]).To(BeEquivalentTo(2))
|
||||
})
|
||||
|
||||
It("previews, then installs with the nonce, and refuses the same approval from the wrong surface", func() {
|
||||
code, prev := do(req{"POST", "/api/addons/host-grants/preview", `{"sourceId":"official","addonId":"newone","version":"1.0.0"}`, ""}, func(r *http.Request) { browser(r, "") })
|
||||
Expect(code).To(Equal(200))
|
||||
nonce := prev["nonce"].(string)
|
||||
body := `{"operationId":"i1","action":"install","sourceId":"official","addonId":"newone","version":"1.0.0","approve":{"digest":"sha256:aa","ops":["workspace.materialize"],"revision":0,"nonce":"` + nonce + `"}}`
|
||||
code, out := do(req{"POST", "/api/addons/host-grants/install", body, "i1"}, func(r *http.Request) { browser(r, "i1"); r.Header.Set("Authorization", "Bearer k") })
|
||||
Expect(code).To(Equal(403))
|
||||
Expect(errType(out)).To(Equal("not_human_surface"))
|
||||
Expect(mgr.submitted).To(BeEmpty())
|
||||
code, out = asHuman(req{"POST", "/api/addons/host-grants/install", body, "i1"})
|
||||
Expect(code).To(Equal(200))
|
||||
Expect(out["Phase"]).To(Equal("completed"))
|
||||
Expect(mgr.submitted).To(HaveLen(1))
|
||||
code, out = asHuman(req{"POST", "/api/addons/host-grants/install", strings.Replace(body, `"i1"`, `"i2"`, 1), "i2"})
|
||||
Expect(code).To(Equal(409), "the nonce is used up")
|
||||
Expect(errType(out)).To(Equal("approval_stale"))
|
||||
})
|
||||
|
||||
It("needs the typed text for the blanket, and a human surface for a deny, but not for its dry run", func() {
|
||||
q := req{"PUT", "/api/addons/host-grants/blanket", `{"operationId":"b1","revision":0,"mode":"any","confirm":"yes"}`, "b1"}
|
||||
code, out := asHuman(q)
|
||||
Expect(code).To(Equal(400))
|
||||
Expect(errType(out)).To(Equal("confirmation_mismatch"))
|
||||
q.body = `{"operationId":"b2","revision":0,"mode":"any","confirm":"` + gateway.BlanketConfirmAny + `"}`
|
||||
q.op = "b2"
|
||||
code, _ = asHuman(q)
|
||||
Expect(code).To(Equal(200))
|
||||
code, out = asHuman(req{"PUT", "/api/addons/host-grants/deny", `{"operationId":"d1","revision":1,"deny":{"sandbox":["workspace."]}}`, "d1"})
|
||||
Expect(code).To(Equal(200))
|
||||
Expect(out["revision"]).To(BeEquivalentTo(2))
|
||||
})
|
||||
})
|
||||
|
||||
func itoa(n int) string {
|
||||
b, _ := json.Marshal(n)
|
||||
return string(b)
|
||||
}
|
||||
@@ -151,6 +151,10 @@ var _ = Describe("registered addon routes with real authentication", func() {
|
||||
// Update detection: the check and the explicit upgrade.
|
||||
"POST /api/addons/:id/update/check": true,
|
||||
"POST /api/addons/:id/upgrade": true,
|
||||
// Host access: the install with an approval, and the preview that
|
||||
// issues its nonce. Browser session only, except the preview.
|
||||
"POST /api/addons/host-grants/preview": true,
|
||||
"POST /api/addons/host-grants/install": true,
|
||||
}))
|
||||
// The grant changes and the conversation selection are PUT and DELETE;
|
||||
// nothing else uses those methods.
|
||||
@@ -165,6 +169,11 @@ var _ = Describe("registered addon routes with real authentication", func() {
|
||||
"DELETE /api/addons/resources/:resourceId/grants/:userId": true,
|
||||
"PUT /api/addons/conversations/:conversationId/selection/:addonId": true,
|
||||
"DELETE /api/addons/conversations/:conversationId/selection/:addonId": true,
|
||||
// Host access: the edit, the blanket switch and the deny rules.
|
||||
"PUT /api/addons/host-grants/blanket": true,
|
||||
"PUT /api/addons/host-grants/deny": true,
|
||||
"PUT /api/addons/host-grants/:addonId": true,
|
||||
"DELETE /api/addons/host-grants/:addonId": true,
|
||||
}))
|
||||
// Reading a proposal, the caller's own pending ones, the owner's list of
|
||||
// unresolved ones and the two private artifact reads are the only
|
||||
@@ -178,6 +187,7 @@ var _ = Describe("registered addon routes with real authentication", func() {
|
||||
Expect(reads).To(Equal(map[string]bool{"/api/addons/proposals": true, "/api/addons/proposals/:id": true, "/api/addons/resources/:resourceId/proposals": true, "/api/addons/artifacts/:id": true, "/api/addons/artifacts/:id/download": true, "/api/addons/apps": true, "/api/addons/apps/:id/icon": true, "/api/addons/:id/assets/:digest/*": true,
|
||||
"/api/addons/:id/recovery": true,
|
||||
"/api/addons/:id/update": true,
|
||||
"/api/addons/host-grants": true,
|
||||
"/api/addons/resources": true, "/api/addons/resources/:resourceId/grants": true, "/api/addons/resources/:resourceId/users": true,
|
||||
"/api/addons/resources/:resourceId/operations/:operationId": true, "/api/addons/conversations/:conversationId/selection/:addonId": true}))
|
||||
})
|
||||
|
||||
@@ -98,6 +98,18 @@ func RegisterAddonRoutesFor(e *echo.Echo, gw *gateway.Gateway, db *gorm.DB, owne
|
||||
group.GET("/:id/update", endpoints.GetUpdate(gw, db, mode))
|
||||
group.POST("/:id/update/check", endpoints.CheckUpdate(gw, db, mode))
|
||||
group.POST("/:id/upgrade", endpoints.UpgradeAddon(gw, db, mode))
|
||||
// Host access: what each add-on may ask the host to do, the deny rules and
|
||||
// the blanket approval. Reads are administrator-only; every write accepts
|
||||
// only the administrator's own browser session, and the frontend forwards
|
||||
// that decision to the owner as a signed field. Never tools: no MCP tool
|
||||
// maps to them.
|
||||
group.GET("/host-grants", endpoints.GetHostGrants(gw, db, mode))
|
||||
group.POST("/host-grants/preview", endpoints.PreviewHostGrants(gw, db, mode))
|
||||
group.POST("/host-grants/install", endpoints.InstallWithHostGrants(gw, db, mode))
|
||||
group.PUT("/host-grants/blanket", endpoints.SetHostGrantsBlanket(gw, db, mode))
|
||||
group.PUT("/host-grants/deny", endpoints.SetHostGrantsDeny(gw, db, mode))
|
||||
group.PUT("/host-grants/:addonId", endpoints.EditHostGrants(gw, db, mode))
|
||||
group.DELETE("/host-grants/:addonId", endpoints.RevokeHostGrants(gw, db, mode))
|
||||
// Immutable UI code of an installed, enabled addon, by package digest. A
|
||||
// read for every caller with the feature; the owner serves only declared
|
||||
// files of the current package. No MCP tool maps to it.
|
||||
|
||||
@@ -299,7 +299,7 @@ var assetPath = regexp.MustCompile(`^[a-zA-Z0-9_./-]+$`)
|
||||
// so an add-on with one of these IDs would have its asset URLs captured by the
|
||||
// host route of the same name. A test derives the registered segments from the
|
||||
// real routes and requires each to be listed here.
|
||||
var reservedAddonIDs = []string{"apps", "proposals", "artifacts", "resources", "conversations", "dispatch", "relay", "catalog", "icon", "assets", "installed", "sources", "builtin", "pins"}
|
||||
var reservedAddonIDs = []string{"apps", "proposals", "artifacts", "resources", "conversations", "dispatch", "relay", "catalog", "icon", "assets", "installed", "sources", "builtin", "pins", "host-grants"}
|
||||
|
||||
// ReservedAddonIDs returns a copy of the reserved add-on IDs.
|
||||
func ReservedAddonIDs() []string { return append([]string(nil), reservedAddonIDs...) }
|
||||
|
||||
@@ -143,7 +143,7 @@ var _ = Describe("Manifest", func() {
|
||||
It("rejects add-on IDs that the host serves as a route segment", func() {
|
||||
// Every first path segment the host uses under /api/addons/ would
|
||||
// otherwise capture the asset URL of an add-on with that ID.
|
||||
Expect(ReservedAddonIDs()).To(ConsistOf("apps", "proposals", "artifacts", "resources", "conversations", "dispatch", "relay", "catalog", "icon", "assets", "installed", "sources", "builtin", "pins"))
|
||||
Expect(ReservedAddonIDs()).To(ConsistOf("apps", "proposals", "artifacts", "resources", "conversations", "dispatch", "relay", "catalog", "icon", "assets", "installed", "sources", "builtin", "pins", "host-grants"))
|
||||
for _, id := range ReservedAddonIDs() {
|
||||
m, _ := ParseManifest(fixture())
|
||||
m.ID = id
|
||||
|
||||
Reference in new issue
Block a user