feat(addons): match the host access routes to the React client

The confirm header names what the administrator acted on: addonId@digest
for an install, the add-on ID for an edit or a revoke, "blanket" and "deny"
for the two switches. The operation ID is optional on every write but the
install; the receiving host makes one when the caller sent none.

Preview and page statuses are allowed, not_allowed, blocked or unsupported
in both. A preview names what an upgrade adds, using the identity of the
installed package. The page carries the library, the new optional access and
the required operations that keep an add-on from running. "keep" relabels
named blanket grants as the administrator's own. The blanket switch ignores
scope options when it is off.

Assisted-by: Claude:claude-sonnet-5-5 go gofmt ginkgo swag
This commit is contained in:
Ettore Di Giacinto committed 2026-11-03 21:55:38 +00:00
1 parent a44a9792e2
commit 270a3a12ea
10 files changed
+406 -171

No files matched your search

+78 -32
View File
@@ -2,6 +2,8 @@
package addons
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"strconv"
@@ -52,12 +54,22 @@ func hostGrantsFailure(c echo.Context, e error) error {
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 {
// The value of the confirm header on each write. It names what the
// administrator acted on, so a form post or a link, which cannot set a custom
// header, cannot make the call: addonId@digest for an install, the add-on ID
// for an edit or a revoke, "blanket" and "deny" for the two switches.
const (
hostGrantsConfirmBlanket = "blanket"
hostGrantsConfirmDeny = "deny"
)
// hostGrantsDo checks the human surface of a write (confirm is the expected
// header value; empty for a read), 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, confirm string) error {
ctx := c.Request().Context()
if call.OperationID != "" {
if !humanSurface(c, s, a, call.OperationID) {
if confirm != "" || call.OperationID != "" {
if !humanSurface(c, s, a, confirm) {
return c.JSON(403, AddonErrorResponse{Error: AddonError{Type: gateway.HostGrantsNotHumanSurface, Code: 403, Message: "This action requires your own browser session"}})
}
ctx = gateway.WithHumanSurface(ctx)
@@ -74,9 +86,22 @@ func hostGrantsDo(c echo.Context, g *gateway.Gateway, s settings, a contract.Act
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 {
// newHostGrantsOperationID makes the operation ID of a write whose caller sent
// none. A caller that wants a retry to be the same change sends its own.
func newHostGrantsOperationID() (string, error) {
var raw [16]byte
if _, err := rand.Read(raw[:]); err != nil {
return "", err
}
return "hg-" + hex.EncodeToString(raw[:]), nil
}
// hostGrantsBody is the shared front of a route with a JSON body:
// administrator check, strict decode, operation ID, call. confirm gives the
// expected confirm header of a write ("" for a read); op may be changed by
// prepare (the deny dry run is a read).
func hostGrantsBody[T any](g *gateway.Gateway, db *gorm.DB, opts []Option, op string,
opOf func(*T) *string, addon func(echo.Context) string, confirm func(echo.Context, *T) string, prepare func(*T) string) echo.HandlerFunc {
s := resolve(opts)
return func(c echo.Context) error {
a, err, ok := hostGrantsAdmin(c, db, s)
@@ -91,14 +116,22 @@ func hostGrantsBody[T any](g *gateway.Gateway, db *gorm.DB, opts []Option, op st
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")
id := opOf(&req)
want := ""
if write {
if *id == "" {
var e error
if *id, e = newHostGrantsOperationID(); e != nil {
return failure(c, 503, "Addons unavailable")
}
}
if !validOperationID(*id) {
return failure(c, 400, "Invalid operationId")
}
want = confirm(c, &req)
} else {
*id = ""
}
body, e := json.Marshal(req)
if e != nil {
@@ -106,16 +139,18 @@ func hostGrantsBody[T any](g *gateway.Gateway, db *gorm.DB, opts []Option, op st
}
call := gateway.HostGrantsCall{Op: callOp, Body: body}
if write {
call.OperationID = id
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)
return hostGrantsDo(c, g, s, a, call, want)
}
}
func addonParam(c echo.Context) string { return c.Param("addonId") }
// 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.
@@ -133,7 +168,7 @@ func GetHostGrants(g *gateway.Gateway, db *gorm.DB, opts ...Option) echo.Handler
if !ok {
return err
}
return hostGrantsDo(c, g, s, a, gateway.HostGrantsCall{Op: gateway.HostGrantsRead})
return hostGrantsDo(c, g, s, a, gateway.HostGrantsCall{Op: gateway.HostGrantsRead}, "")
}
}
@@ -151,12 +186,12 @@ func GetHostGrants(g *gateway.Gateway, db *gorm.DB, opts ...Option) echo.Handler
// @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)
return hostGrantsBody(g, db, opts, gateway.HostGrantsPreview, func(*gateway.HostGrantsPreviewRequest) *string { var none string; return &none }, nil, 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.
// @Description Administrators only, from their own browser session (same-origin fetch, X-LocalAI-Confirm equal to addonId@digest, the add-on and the digest of the approved package). 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
@@ -169,12 +204,15 @@ func PreviewHostGrants(g *gateway.Gateway, db *gorm.DB, opts ...Option) echo.Han
// @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)
return hostGrantsBody(g, db, opts, gateway.HostGrantsInstall, func(r *gateway.HostGrantsInstallRequest) *string { return &r.OperationID }, nil,
func(_ echo.Context, r *gateway.HostGrantsInstallRequest) string {
return r.AddonID + "@" + r.Approve.Digest
}, 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.
// @Description Administrators only, from their own browser session (X-LocalAI-Confirm equal to the add-on ID). operationId is optional (the host makes one); send your own to make a retry the same change. 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. keep names held blanket grants to relabel 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
@@ -189,17 +227,18 @@ func InstallWithHostGrants(g *gateway.Gateway, db *gorm.DB, opts ...Option) echo
// @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)
return hostGrantsBody(g, db, opts, gateway.HostGrantsEdit, func(r *gateway.HostGrantsEditRequest) *string { return &r.OperationID }, addonParam,
func(c echo.Context, _ *gateway.HostGrantsEditRequest) 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.
// @Description Administrators only, from their own browser session (X-LocalAI-Confirm equal to the add-on ID). Removes the UI and blanket grants of the add-on; grants of the policy file stay. Query: revision (the revision the caller read) and, optionally, 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"
// @Param operationId query string false "Operation ID (optional)"
// @Success 200 {object} gateway.HostGrantsRevision "The new revision"
// @Failure 400 {object} AddonErrorResponse "Malformed request"
// @Failure 401 {object} AddonErrorResponse "Named authentication required"
@@ -217,19 +256,25 @@ func RevokeHostGrants(g *gateway.Gateway, db *gorm.DB, opts ...Option) echo.Hand
}
rev, perr := strconv.ParseUint(c.QueryParam("revision"), 10, 64)
id := c.QueryParam("operationId")
if id == "" {
var e error
if id, e = newHostGrantsOperationID(); e != nil {
return failure(c, 503, "Addons unavailable")
}
}
if perr != nil || !validOperationID(id) {
return failure(c, 400, "revision and operationId are required")
return failure(c, 400, "revision is 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)
return hostGrantsDo(c, g, s, a, call, c.Param("addonId"))
}
}
// 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.
// @Description Administrators only, from their own browser session (X-LocalAI-Confirm equal to "blanket"). 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
@@ -242,12 +287,13 @@ func RevokeHostGrants(g *gateway.Gateway, db *gorm.DB, opts ...Option) echo.Hand
// @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)
return hostGrantsBody(g, db, opts, gateway.HostGrantsBlanket, func(r *gateway.HostGrantsBlanketRequest) *string { return &r.OperationID }, nil,
func(echo.Context, *gateway.HostGrantsBlanketRequest) string { return hostGrantsConfirmBlanket }, 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.
// @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 "deny"), the revision the caller read (409 revision_conflict otherwise), and applies to the next call with no restart.
// @Tags Addons
// @Accept json
// @Produce json
@@ -260,11 +306,11 @@ func SetHostGrantsBlanket(g *gateway.Gateway, db *gorm.DB, opts ...Option) echo.
// @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,
return hostGrantsBody(g, db, opts, gateway.HostGrantsDeny, func(r *gateway.HostGrantsDenyRequest) *string { return &r.OperationID }, nil,
func(echo.Context, *gateway.HostGrantsDenyRequest) string { return hostGrantsConfirmDeny },
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
@@ -67,7 +67,7 @@ var _ = Describe("host access routes with real sessions and API keys", func() {
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")
r.Header.Set(ConfirmHeader, "sandbox")
}
}
// The session of an administrator passes every gate.
@@ -75,7 +75,7 @@ var _ = Describe("host access routes with real sessions and API keys", func() {
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")
r.Header.Set(ConfirmHeader, "sandbox")
})).To(Equal(403), "an administrator API key")
Expect(put(func(r *http.Request) {
tab(adminSession)(r)
+41 -18
View File
@@ -71,6 +71,7 @@ var _ = Describe("host access routes", func() {
registerHostGrants(e, &gateway.Gateway{HostGrantsOwner: svc, Revalidate: func(context.Context, contract.Actor) error { return nil }})
})
// confirm is the value the confirm header must carry.
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) {
@@ -104,16 +105,16 @@ var _ = Describe("host access routes", func() {
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}
return req{"PUT", "/api/addons/host-grants/sandbox", `{"operationId":"` + op + `","revision":` + itoa(rev) + `,"ops":` + ops + `}`, "sandbox"}
}
// 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"},
{"DELETE", "/api/addons/host-grants/sandbox?revision=0&operationId=w2", "", "sandbox"},
{"PUT", "/api/addons/host-grants/blanket", `{"operationId":"w3","revision":0,"mode":"off"}`, "blanket"},
{"PUT", "/api/addons/host-grants/deny", `{"operationId":"w4","revision":0,"deny":{}}`, "deny"},
{"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"}}`, "newone@sha256:aa"},
}
It("serves the reads to an administrator by API key as well", func() {
@@ -128,6 +129,28 @@ var _ = Describe("host access routes", func() {
}
})
It("answers the shapes the React client reads", func() {
_, ov := do(req{"GET", "/api/addons/host-grants", "", ""}, func(r *http.Request) { browser(r, "") })
for _, k := range []string{"revision", "locked", "blanket", "floor", "deny", "addons"} {
Expect(ov).To(HaveKey(k))
}
Expect(ov["blanket"]).To(HaveKeyWithValue("setBy", "ui"))
sbx := ov["addons"].([]any)[0].(map[string]any)
for _, k := range []string{"id", "title", "version", "sourceId", "state", "requested", "ops", "noLongerRequested", "newOptional", "identityMismatch", "stopped"} {
if k == "sourceId" {
continue // empty until the manager reports the library
}
Expect(sbx).To(HaveKey(k), k)
}
for _, o := range sbx["ops"].([]any) {
Expect(o.(map[string]any)["status"]).To(BeElementOf("allowed", "not_allowed", "blocked", "unsupported"))
}
_, pv := do(req{"POST", "/api/addons/host-grants/preview", `{"sourceId":"official","addonId":"newone","version":"1.0.0"}`, ""}, func(r *http.Request) { browser(r, "") })
for _, k := range []string{"digest", "version", "revision", "nonce", "official", "added", "requested", "ops", "blockedRequired", "canInstall"} {
Expect(pv).To(HaveKey(k), k)
}
})
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 {
@@ -177,23 +200,23 @@ var _ = Describe("host access routes", 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"})
code, out = asHuman(req{"PUT", "/api/addons/host-grants/ghost", `{"operationId":"e2","revision":0,"ops":["egress.check"]}`, "ghost"})
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"})
code, _ = asHuman(req{"PUT", "/api/addons/host-grants/sandbox", `{"operationId":"e3","revision":0,"ops":[],"surprise":1}`, "sandbox"})
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"})
code, _ = asHuman(req{"PUT", "/api/addons/host-grants/sandbox", `{"revision":0,"ops":[]}`, "sandbox"})
Expect(code).To(Equal(200), "the host makes an operation ID when the caller sent none")
code, _ = asHuman(req{"DELETE", "/api/addons/host-grants/sandbox?operationId=r1", "", "sandbox"})
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"})
code, out := asHuman(req{"DELETE", "/api/addons/host-grants/sandbox?revision=0&operationId=r1", "", "sandbox"})
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"})
code, out = asHuman(req{"DELETE", "/api/addons/host-grants/sandbox?revision=1&operationId=r2", "", "sandbox"})
Expect(code).To(Equal(200))
Expect(out["revision"]).To(BeEquivalentTo(2))
})
@@ -203,29 +226,29 @@ var _ = Describe("host access routes", func() {
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") })
code, out := do(req{"POST", "/api/addons/host-grants/install", body, "i1"}, func(r *http.Request) { browser(r, "newone@sha256:aa"); 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"})
code, out = asHuman(req{"POST", "/api/addons/host-grants/install", body, "newone@sha256:aa"})
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"})
code, out = asHuman(req{"POST", "/api/addons/host-grants/install", strings.Replace(body, `"i1"`, `"i2"`, 1), "newone@sha256:aa"})
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"}
q := req{"PUT", "/api/addons/host-grants/blanket", `{"operationId":"b1","revision":0,"mode":"any","confirm":"yes"}`, "blanket"}
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"
q.op = "blanket"
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"})
code, out = asHuman(req{"PUT", "/api/addons/host-grants/deny", `{"operationId":"d1","revision":1,"deny":{"sandbox":["workspace."]}}`, "deny"})
Expect(code).To(Equal(200))
Expect(out["revision"]).To(BeEquivalentTo(2))
})
File diff suppressed because one or more lines are too long.
+40 -28
View File
@@ -228,6 +228,11 @@ type InstalledAccess struct {
Official bool
Required, Optional []string
PendingUpgrade *PendingUpgrade
// SourceID is the library the add-on came from.
SourceID string
// NewOptional are optional operations the installed version asks for
// that an upgrade added and that nothing grants yet.
NewOptional []string
}
// HostAccessManager is what the owner service needs from the add-on manager.
@@ -279,13 +284,12 @@ type HostGrantsInstallRequest struct {
// HostGrantsEditRequest is the body of PUT /api/addons/host-grants/{addonId}.
type HostGrantsEditRequest struct {
OperationID string `json:"operationId"`
Revision uint64 `json:"revision"`
// Ops is the full set of operations the add-on keeps through the UI.
Ops []string `json:"ops"`
// KeepBlanketAsOwn relabels the blanket grants of the add-on as the
// administrator's own, so revoking the blanket grants leaves them.
KeepBlanketAsOwn bool `json:"keepBlanketAsOwn,omitempty"`
OperationID string `json:"operationId"`
Revision uint64 `json:"revision"`
Ops []string `json:"ops"`
// Keep names held blanket grants to relabel as the administrator's own,
// so revoking the blanket grants leaves them.
Keep []string `json:"keep,omitempty"`
}
// HostGrantsRevokeRequest is the body the route builds from the query of
@@ -331,17 +335,17 @@ type HostOpView struct {
Title string `json:"title,omitempty"`
Text string `json:"text,omitempty"`
Risk string `json:"risk,omitempty"`
// Status is allowed, not_allowed, blocked or unsupported. In a preview it
// is held, grantable, blocked or unsupported.
// Status is allowed, not_allowed, blocked or unsupported, in the page and
// in a preview alike. In a preview not_allowed means the dialog may grant it.
Status string `json:"status"`
Source string `json:"source,omitempty"`
Reason string `json:"reason,omitempty"`
At string `json:"at,omitempty"`
By string `json:"by,omitempty"`
Required bool `json:"required,omitempty"`
// Hinted is true in a preview when the operation was granted before an
// earlier uninstall, so the dialog may tick it.
Hinted bool `json:"hinted,omitempty"`
// Hint is true in a preview when the operation was granted before an
// earlier uninstall, so the dialog may tick it. It grants nothing.
Hint bool `json:"hint,omitempty"`
}
// HostRequested lists what a manifest asks for.
@@ -352,16 +356,21 @@ type HostRequested struct {
// HostGrantsAddon is the entry of one installed add-on.
type HostGrantsAddon struct {
ID string `json:"id"`
Title string `json:"title"`
Version string `json:"version"`
State string `json:"state"`
Requested HostRequested `json:"requested"`
Ops []HostOpView `json:"ops"`
NoLongerRequested []HostOpView `json:"noLongerRequested"`
IdentityMismatch bool `json:"identityMismatch"`
PendingUpgrade *PendingUpgrade `json:"pendingUpgrade,omitempty"`
BlanketGrantsCount int `json:"blanketGrants"`
ID string `json:"id"`
Title string `json:"title"`
Version string `json:"version"`
State string `json:"state"`
Requested HostRequested `json:"requested"`
Ops []HostOpView `json:"ops"`
NoLongerRequested []HostOpView `json:"noLongerRequested"`
IdentityMismatch bool `json:"identityMismatch"`
PendingUpgrade *PendingUpgrade `json:"pendingUpgrade,omitempty"`
SourceID string `json:"sourceId,omitempty"`
NewOptional []string `json:"newOptional"`
// Stopped lists the required operations the add-on lacks, which keep it
// from running ("stopped: needs X").
Stopped string `json:"stopped,omitempty"`
BlanketGrantsCount int `json:"blanketGrants"`
}
// HostBlanketView is the blanket switch as the page shows it.
@@ -409,12 +418,15 @@ type HostBlanketPreview struct {
// HostGrantsPreviewView is the response of the preview.
type HostGrantsPreviewView struct {
AddonID string `json:"addonId"`
Title string `json:"title"`
Digest string `json:"digest"`
Version string `json:"version"`
Installed bool `json:"installed"`
Official bool `json:"official"`
AddonID string `json:"addonId"`
Title string `json:"title"`
Digest string `json:"digest"`
Version string `json:"version"`
Installed bool `json:"installed"`
Official bool `json:"official"`
// Added are the operations this version asks for that the installed
// version did not. Empty on an install.
Added []string `json:"added"`
Requested HostRequested `json:"requested"`
Ops []HostOpView `json:"ops"`
CanInstall bool `json:"canInstall"`
@@ -334,7 +334,7 @@ func (o *HostGrantsOwner) addonView(doc hostgrants.Snapshot, in InstalledAccess)
for _, g := range entry.Ops {
grant[g.Name] = g
}
v := HostGrantsAddon{ID: in.AddonID, Title: in.Title, Version: in.Version, State: in.State, PendingUpgrade: in.PendingUpgrade,
v := HostGrantsAddon{ID: in.AddonID, Title: in.Title, Version: in.Version, State: in.State, PendingUpgrade: in.PendingUpgrade, SourceID: in.SourceID, NewOptional: nonNil(sorted(in.NewOptional)),
Requested: HostRequested{Required: nonNil(sorted(in.Required)), Optional: nonNil(sorted(in.Optional))},
Ops: []HostOpView{}, NoLongerRequested: []HostOpView{}}
view := func(op string) HostOpView {
@@ -352,9 +352,15 @@ func (o *HostGrantsOwner) addonView(doc hostgrants.Snapshot, in InstalledAccess)
o.describe(&ov)
return ov
}
var lacking []string
for _, op := range declared {
v.Ops = append(v.Ops, view(op))
ov := view(op)
v.Ops = append(v.Ops, ov)
if ov.Required && ov.Status != "allowed" {
lacking = append(lacking, op)
}
}
v.Stopped = strings.Join(lacking, ", ")
for _, g := range entry.Ops {
if g.Source == hostgrants.SourceBlanket {
v.BlanketGrantsCount++
@@ -432,10 +438,23 @@ func (o *HostGrantsOwner) preview(ctx context.Context, a contract.Actor, r HostG
if err != nil {
return HostGrantsPreviewView{}, err
}
// The verified manifest of the staged package is the only truth. The
// identity of a not yet installed package is unknown, so a UI entry of an
// older install of the same ID never counts as held here.
c := o.context("")
// The verified manifest of the staged package is the only truth. For an
// upgrade the identity of the installed package says what is held already;
// a package that is not installed yet holds nothing from the UI.
var installed InstalledAccess
identity := ""
if rel.Installed {
list, lerr := o.cfg.Manager.HostAccess(ctx)
if lerr != nil {
return HostGrantsPreviewView{}, lerr
}
for _, in := range list {
if in.AddonID == rel.AddonID {
installed, identity = in, in.Identity
}
}
}
c := o.context(identity)
declared := unique(rel.Required, rel.Optional)
plan := o.cfg.Floor.Plan(doc, c, hostgrants.PlanInput{AddonID: rel.AddonID, Required: rel.Required, Optional: rel.Optional, Wanted: declared})
req := map[string]bool{}
@@ -444,10 +463,11 @@ func (o *HostGrantsOwner) preview(ctx context.Context, a contract.Actor, r HostG
}
hint := doc.Hints[rel.AddonID]
v := HostGrantsPreviewView{AddonID: rel.AddonID, Title: rel.Title, Digest: rel.Digest, Version: rel.Version, Installed: rel.Installed,
Official: rel.Official, Revision: doc.Revision, Ops: []HostOpView{}, BlockedReq: nonNil(plan.BlockedRequired),
Official: rel.Official, Revision: doc.Revision, Ops: []HostOpView{}, BlockedReq: nonNil(plan.BlockedRequired), Added: []string{},
Requested: HostRequested{Required: nonNil(sorted(rel.Required)), Optional: nonNil(sorted(rel.Optional))}}
before := unique(installed.Required, installed.Optional)
for _, op := range declared {
ov := HostOpView{Name: op, Required: req[op], Hinted: slices.Contains(hint, op)}
ov := HostOpView{Name: op, Required: req[op], Hint: slices.Contains(hint, op)}
switch {
case slices.Contains(plan.Denied, op):
_, why := o.cfg.Floor.Denied(doc, rel.AddonID, op)
@@ -455,13 +475,16 @@ func (o *HostGrantsOwner) preview(ctx context.Context, a contract.Actor, r HostG
case slices.Contains(plan.Unsupported, op):
ov.Status = "unsupported"
case slices.Contains(plan.Held, op):
ov.Status = "held"
ov.Source = string(hostgrants.SourceDeployment)
ov.Status = "allowed"
ov.Source = string(o.cfg.Floor.Decide(doc, c, rel.AddonID, op).Source)
default:
ov.Status = "grantable"
ov.Status = "not_allowed"
}
o.describe(&ov)
v.Ops = append(v.Ops, ov)
if rel.Installed && !slices.Contains(before, op) {
v.Added = append(v.Added, op)
}
}
v.CanInstall = onlyGrantableMissing(plan, rel.Required)
b := o.cfg.Floor.EvaluateBlanket(doc, c, o.cfg.BlanketOverride, hostgrants.BlanketInput{AddonID: rel.AddonID, Official: rel.Official, Required: rel.Required, Optional: rel.Optional})
@@ -602,8 +625,17 @@ func (o *HostGrantsOwner) edit(ctx context.Context, a contract.Actor, id string,
removed = append(removed, g.Name)
}
}
if r.KeepBlanketAsOwn {
next = hostgrants.KeepAsOwn(next, o.now(), a.UserID)
if len(r.Keep) != 0 {
// Relabel only the blanket grants the caller named and still keeps.
keep := map[string]bool{}
for _, k := range r.Keep {
keep[k] = true
}
for i, g := range next.Ops {
if keep[g.Name] && g.Source == hostgrants.SourceBlanket {
next.Ops[i] = hostgrants.OpGrant{Name: g.Name, Source: hostgrants.SourceEdit, At: o.now(), By: a.UserID, Version: g.Version}
}
}
}
var patch hostgrants.Patch
if len(next.Ops) == 0 {
@@ -682,7 +714,11 @@ func (o *HostGrantsOwner) blanket(ctx context.Context, a contract.Actor, r HostG
default:
return nil, o.invalid()
}
if (r.Mode == hostgrants.BlanketOff && (r.IncludeOptional || r.ApplyToInstalled)) || (r.Mode != hostgrants.BlanketOff && r.RevokeOnOff) {
if r.Mode == hostgrants.BlanketOff {
// Off has no scope to widen: the options are ignored, not refused.
r.IncludeOptional, r.ApplyToInstalled = false, false
}
if r.Mode != hostgrants.BlanketOff && r.RevokeOnOff {
return nil, o.invalid()
}
doc, err := o.cfg.Store.Snapshot(ctx)
@@ -174,8 +174,8 @@ var _ = Describe("The host access owner service", func() {
Expect(v.Ops).To(HaveLen(2))
Expect(v.Ops[1].Name).To(Equal("workspace.materialize"))
Expect(v.Ops[1].Required).To(BeTrue())
Expect(v.Ops[1].Status).To(Equal("grantable"))
Expect(v.Ops[0].Status).To(Equal("grantable"))
Expect(v.Ops[1].Status).To(Equal("not_allowed"))
Expect(v.Ops[0].Status).To(Equal("not_allowed"))
Expect(v.Blanket.Applies).To(BeFalse())
})
It("shows a deny winning over everything, and cannot install a blocked required operation", func() {
@@ -206,10 +206,10 @@ var _ = Describe("The host access owner service", func() {
for _, o := range v.Ops {
switch o.Name {
case "egress.check":
Expect(o.Status).To(Equal("held"))
Expect(o.Status).To(Equal("allowed"))
case "workspace.materialize":
Expect(o.Status).To(Equal("grantable"))
Expect(o.Hinted).To(BeTrue())
Expect(o.Status).To(Equal("not_allowed"))
Expect(o.Hint).To(BeTrue())
}
}
})
@@ -449,9 +449,11 @@ var _ = Describe("The host access owner service", func() {
})
It("rejects options that do not fit the mode", func() {
f := newHGFixture()
_, err := b(f, "b1", 0, HostGrantsBlanketRequest{Mode: "off", IncludeOptional: true})
_, err := b(f, "b1", 0, HostGrantsBlanketRequest{Mode: "off", IncludeOptional: true, ApplyToInstalled: true})
Expect(err).NotTo(HaveOccurred(), "off ignores the scope options")
_, err = b(f, "b4", 1, HostGrantsBlanketRequest{Mode: "official", RevokeOnOff: true, Confirm: BlanketConfirmOfficial})
Expect(typeOf(err)).To(Equal("invalid_request"))
_, err = b(f, "b2", 0, HostGrantsBlanketRequest{Mode: "sometimes"})
_, err = b(f, "b2", 1, HostGrantsBlanketRequest{Mode: "sometimes"})
Expect(typeOf(err)).To(Equal("invalid_request"))
})
})
@@ -531,3 +533,57 @@ var _ = Describe("host access actions on the typed dispatch", func() {
}
})
})
var _ = Describe("host access as the pages read it", func() {
It("names what an upgrade adds, using the installed package to tell what is held", func() {
f := newHGFixture()
_, err := f.svc.Serve(human, admin, call(HostGrantsEdit, "e1", "sandbox", HostGrantsEditRequest{OperationID: "e1", Ops: []string{"workspace.materialize"}}))
Expect(err).NotTo(HaveOccurred())
f.mgr.release = ReleaseAccess{AddonID: "sandbox", Title: "Sandbox", Version: "1.1.0", Digest: "sha256:bb", Installed: true,
Required: []string{"workspace.materialize", "workspace.export"}, Optional: []string{"egress.check"}}
v := data[HostGrantsPreviewView](f.svc.Serve(context.Background(), admin, call(HostGrantsPreview, "", "", HostGrantsPreviewRequest{SourceID: "official", AddonID: "sandbox", Version: "1.1.0"})))
Expect(v.Added).To(Equal([]string{"workspace.export"}))
status := map[string]string{}
for _, o := range v.Ops {
status[o.Name] = o.Status
}
Expect(status).To(Equal(map[string]string{"workspace.materialize": "allowed", "workspace.export": "not_allowed", "egress.check": "not_allowed"}))
})
It("says why an add-on is stopped, and carries its library and new optional access", func() {
f := newHGFixture()
f.mgr.installed[0].SourceID = "official"
f.mgr.installed[0].NewOptional = []string{"egress.check"}
v := data[HostGrantsView](f.svc.Serve(context.Background(), admin, call(HostGrantsRead, "", "", nil)))
var sbx HostGrantsAddon
for _, a := range v.Addons {
if a.ID == "sandbox" {
sbx = a
}
}
Expect(sbx.Stopped).To(Equal("workspace.materialize"))
Expect(sbx.SourceID).To(Equal("official"))
Expect(sbx.NewOptional).To(Equal([]string{"egress.check"}))
})
It("keeps a blanket grant as the administrator's own, so revoking the blanket grants leaves it", func() {
f := newHGFixture()
_, err := f.svc.Serve(human, admin, call(HostGrantsBlanket, "b1", "", HostGrantsBlanketRequest{OperationID: "b1", Mode: "any", ApplyToInstalled: true, Confirm: BlanketConfirmAny}))
Expect(err).NotTo(HaveOccurred())
r := data[HostGrantsAddonResult](f.svc.Serve(human, admin, call(HostGrantsEdit, "e1", "sandbox", HostGrantsEditRequest{OperationID: "e1", Revision: 1, Ops: []string{"workspace.materialize"}, Keep: []string{"workspace.materialize"}})))
Expect(r.Addon.Ops[2].Name).To(Equal("workspace.materialize"))
Expect(r.Addon.Ops[2].Source).To(Equal("edit"))
_, err = f.svc.Serve(human, admin, call(HostGrantsBlanket, "b2", "", HostGrantsBlanketRequest{OperationID: "b2", Revision: 2, Mode: "off", RevokeOnOff: true}))
Expect(err).NotTo(HaveOccurred())
v := data[HostGrantsView](f.svc.Serve(context.Background(), admin, call(HostGrantsRead, "", "", nil)))
for _, a := range v.Addons {
if a.ID == "sandbox" {
for _, o := range a.Ops {
if o.Name == "workspace.materialize" {
Expect(o.Status).To(Equal("allowed"))
}
}
}
}
})
})
+36 -15
View File
@@ -792,7 +792,7 @@ const docTemplate = `{
},
"/api/addons/host-grants/blanket": {
"put": {
"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.",
"description": "Administrators only, from their own browser session (X-LocalAI-Confirm equal to \"blanket\"). 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.",
"consumes": [
"application/json"
],
@@ -856,7 +856,7 @@ const docTemplate = `{
},
"/api/addons/host-grants/deny": {
"put": {
"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.",
"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 \"deny\"), the revision the caller read (409 revision_conflict otherwise), and applies to the next call with no restart.",
"consumes": [
"application/json"
],
@@ -920,7 +920,7 @@ const docTemplate = `{
},
"/api/addons/host-grants/install": {
"post": {
"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.",
"description": "Administrators only, from their own browser session (same-origin fetch, X-LocalAI-Confirm equal to addonId@digest, the add-on and the digest of the approved package). 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.",
"consumes": [
"application/json"
],
@@ -1042,7 +1042,7 @@ const docTemplate = `{
},
"/api/addons/host-grants/{addonId}": {
"put": {
"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.",
"description": "Administrators only, from their own browser session (X-LocalAI-Confirm equal to the add-on ID). operationId is optional (the host makes one); send your own to make a retry the same change. 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. keep names held blanket grants to relabel 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.",
"consumes": [
"application/json"
],
@@ -1117,7 +1117,7 @@ const docTemplate = `{
}
},
"delete": {
"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.",
"description": "Administrators only, from their own browser session (X-LocalAI-Confirm equal to the add-on ID). Removes the UI and blanket grants of the add-on; grants of the policy file stay. Query: revision (the revision the caller read) and, optionally, operationId. Takes effect for the next call with no restart; a running call finishes. An add-on whose required operation is revoked becomes unavailable.",
"produces": [
"application/json"
],
@@ -1142,10 +1142,9 @@ const docTemplate = `{
},
{
"type": "string",
"description": "Operation ID",
"description": "Operation ID (optional)",
"name": "operationId",
"in": "query",
"required": true
"in": "query"
}
],
"responses": {
@@ -17414,6 +17413,12 @@ const docTemplate = `{
"identityMismatch": {
"type": "boolean"
},
"newOptional": {
"type": "array",
"items": {
"type": "string"
}
},
"noLongerRequested": {
"type": "array",
"items": {
@@ -17432,9 +17437,16 @@ const docTemplate = `{
"requested": {
"$ref": "#/definitions/gateway.HostRequested"
},
"sourceId": {
"type": "string"
},
"state": {
"type": "string"
},
"stopped": {
"description": "Stopped lists the required operations the add-on lacks, which keep it\nfrom running (\"stopped: needs X\").",
"type": "string"
},
"title": {
"type": "string"
},
@@ -17607,15 +17619,17 @@ const docTemplate = `{
"gateway.HostGrantsEditRequest": {
"type": "object",
"properties": {
"keepBlanketAsOwn": {
"description": "KeepBlanketAsOwn relabels the blanket grants of the add-on as the\nadministrator's own, so revoking the blanket grants leaves them.",
"type": "boolean"
"keep": {
"description": "Keep names held blanket grants to relabel as the administrator's own,\nso revoking the blanket grants leaves them.",
"type": "array",
"items": {
"type": "string"
}
},
"operationId": {
"type": "string"
},
"ops": {
"description": "Ops is the full set of operations the add-on keeps through the UI.",
"type": "array",
"items": {
"type": "string"
@@ -17688,6 +17702,13 @@ const docTemplate = `{
"gateway.HostGrantsPreviewView": {
"type": "object",
"properties": {
"added": {
"description": "Added are the operations this version asks for that the installed\nversion did not. Empty on an install.",
"type": "array",
"items": {
"type": "string"
}
},
"addonId": {
"type": "string"
},
@@ -17784,8 +17805,8 @@ const docTemplate = `{
"by": {
"type": "string"
},
"hinted": {
"description": "Hinted is true in a preview when the operation was granted before an\nearlier uninstall, so the dialog may tick it.",
"hint": {
"description": "Hint is true in a preview when the operation was granted before an\nearlier uninstall, so the dialog may tick it. It grants nothing.",
"type": "boolean"
},
"name": {
@@ -17804,7 +17825,7 @@ const docTemplate = `{
"type": "string"
},
"status": {
"description": "Status is allowed, not_allowed, blocked or unsupported. In a preview it\nis held, grantable, blocked or unsupported.",
"description": "Status is allowed, not_allowed, blocked or unsupported, in the page and\nin a preview alike. In a preview not_allowed means the dialog may grant it.",
"type": "string"
},
"text": {
+36 -15
View File
@@ -789,7 +789,7 @@
},
"/api/addons/host-grants/blanket": {
"put": {
"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.",
"description": "Administrators only, from their own browser session (X-LocalAI-Confirm equal to \"blanket\"). 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.",
"consumes": [
"application/json"
],
@@ -853,7 +853,7 @@
},
"/api/addons/host-grants/deny": {
"put": {
"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.",
"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 \"deny\"), the revision the caller read (409 revision_conflict otherwise), and applies to the next call with no restart.",
"consumes": [
"application/json"
],
@@ -917,7 +917,7 @@
},
"/api/addons/host-grants/install": {
"post": {
"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.",
"description": "Administrators only, from their own browser session (same-origin fetch, X-LocalAI-Confirm equal to addonId@digest, the add-on and the digest of the approved package). 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.",
"consumes": [
"application/json"
],
@@ -1039,7 +1039,7 @@
},
"/api/addons/host-grants/{addonId}": {
"put": {
"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.",
"description": "Administrators only, from their own browser session (X-LocalAI-Confirm equal to the add-on ID). operationId is optional (the host makes one); send your own to make a retry the same change. 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. keep names held blanket grants to relabel 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.",
"consumes": [
"application/json"
],
@@ -1114,7 +1114,7 @@
}
},
"delete": {
"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.",
"description": "Administrators only, from their own browser session (X-LocalAI-Confirm equal to the add-on ID). Removes the UI and blanket grants of the add-on; grants of the policy file stay. Query: revision (the revision the caller read) and, optionally, operationId. Takes effect for the next call with no restart; a running call finishes. An add-on whose required operation is revoked becomes unavailable.",
"produces": [
"application/json"
],
@@ -1139,10 +1139,9 @@
},
{
"type": "string",
"description": "Operation ID",
"description": "Operation ID (optional)",
"name": "operationId",
"in": "query",
"required": true
"in": "query"
}
],
"responses": {
@@ -17411,6 +17410,12 @@
"identityMismatch": {
"type": "boolean"
},
"newOptional": {
"type": "array",
"items": {
"type": "string"
}
},
"noLongerRequested": {
"type": "array",
"items": {
@@ -17429,9 +17434,16 @@
"requested": {
"$ref": "#/definitions/gateway.HostRequested"
},
"sourceId": {
"type": "string"
},
"state": {
"type": "string"
},
"stopped": {
"description": "Stopped lists the required operations the add-on lacks, which keep it\nfrom running (\"stopped: needs X\").",
"type": "string"
},
"title": {
"type": "string"
},
@@ -17604,15 +17616,17 @@
"gateway.HostGrantsEditRequest": {
"type": "object",
"properties": {
"keepBlanketAsOwn": {
"description": "KeepBlanketAsOwn relabels the blanket grants of the add-on as the\nadministrator's own, so revoking the blanket grants leaves them.",
"type": "boolean"
"keep": {
"description": "Keep names held blanket grants to relabel as the administrator's own,\nso revoking the blanket grants leaves them.",
"type": "array",
"items": {
"type": "string"
}
},
"operationId": {
"type": "string"
},
"ops": {
"description": "Ops is the full set of operations the add-on keeps through the UI.",
"type": "array",
"items": {
"type": "string"
@@ -17685,6 +17699,13 @@
"gateway.HostGrantsPreviewView": {
"type": "object",
"properties": {
"added": {
"description": "Added are the operations this version asks for that the installed\nversion did not. Empty on an install.",
"type": "array",
"items": {
"type": "string"
}
},
"addonId": {
"type": "string"
},
@@ -17781,8 +17802,8 @@
"by": {
"type": "string"
},
"hinted": {
"description": "Hinted is true in a preview when the operation was granted before an\nearlier uninstall, so the dialog may tick it.",
"hint": {
"description": "Hint is true in a preview when the operation was granted before an\nearlier uninstall, so the dialog may tick it. It grants nothing.",
"type": "boolean"
},
"name": {
@@ -17801,7 +17822,7 @@
"type": "string"
},
"status": {
"description": "Status is allowed, not_allowed, blocked or unsupported. In a preview it\nis held, grantable, blocked or unsupported.",
"description": "Status is allowed, not_allowed, blocked or unsupported, in the page and\nin a preview alike. In a preview not_allowed means the dialog may grant it.",
"type": "string"
},
"text": {
+59 -39
View File
@@ -1749,6 +1749,10 @@ definitions:
type: string
identityMismatch:
type: boolean
newOptional:
items:
type: string
type: array
noLongerRequested:
items:
$ref: '#/definitions/gateway.HostOpView'
@@ -1761,8 +1765,15 @@ definitions:
$ref: '#/definitions/gateway.PendingUpgrade'
requested:
$ref: '#/definitions/gateway.HostRequested'
sourceId:
type: string
state:
type: string
stopped:
description: |-
Stopped lists the required operations the add-on lacks, which keep it
from running ("stopped: needs X").
type: string
title:
type: string
version:
@@ -1879,16 +1890,16 @@ definitions:
type: object
gateway.HostGrantsEditRequest:
properties:
keepBlanketAsOwn:
keep:
description: |-
KeepBlanketAsOwn relabels the blanket grants of the add-on as the
administrator's own, so revoking the blanket grants leaves them.
type: boolean
Keep names held blanket grants to relabel as the administrator's own,
so revoking the blanket grants leaves them.
items:
type: string
type: array
operationId:
type: string
ops:
description: Ops is the full set of operations the add-on keeps through the
UI.
items:
type: string
type: array
@@ -1936,6 +1947,13 @@ definitions:
type: object
gateway.HostGrantsPreviewView:
properties:
added:
description: |-
Added are the operations this version asks for that the installed
version did not. Empty on an install.
items:
type: string
type: array
addonId:
type: string
blanket:
@@ -1999,10 +2017,10 @@ definitions:
type: string
by:
type: string
hinted:
hint:
description: |-
Hinted is true in a preview when the operation was granted before an
earlier uninstall, so the dialog may tick it.
Hint is true in a preview when the operation was granted before an
earlier uninstall, so the dialog may tick it. It grants nothing.
type: boolean
name:
type: string
@@ -2016,8 +2034,8 @@ definitions:
type: string
status:
description: |-
Status is allowed, not_allowed, blocked or unsupported. In a preview it
is held, grantable, blocked or unsupported.
Status is allowed, not_allowed, blocked or unsupported, in the page and
in a preview alike. In a preview not_allowed means the dialog may grant it.
type: string
text:
type: string
@@ -11841,10 +11859,11 @@ paths:
/api/addons/host-grants/{addonId}:
delete:
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;
equal to the add-on ID). 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.'
and, optionally, operationId. Takes effect for the next call with no restart;
a running call finishes. An add-on whose required operation is revoked becomes
unavailable.'
parameters:
- description: Add-on ID
in: path
@@ -11856,10 +11875,9 @@ paths:
name: revision
required: true
type: integer
- description: Operation ID
- description: Operation ID (optional)
in: query
name: operationId
required: true
type: string
produces:
- application/json
@@ -11900,11 +11918,12 @@ paths:
consumes:
- application/json
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
equal to the add-on ID). operationId is optional (the host makes one); send
your own to make a retry the same change. 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. keep names held blanket
grants to relabel 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.'
parameters:
@@ -11959,9 +11978,9 @@ paths:
consumes:
- application/json
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
equal to "blanket"). 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
@@ -12023,7 +12042,7 @@ paths:
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 administrator''s own browser session (X-LocalAI-Confirm equal to "deny"),
the revision the caller read (409 revision_conflict otherwise), and applies
to the next call with no restart.'
parameters:
@@ -12069,20 +12088,21 @@ paths:
consumes:
- application/json
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.'
fetch, X-LocalAI-Confirm equal to addonId@digest, the add-on and the digest
of the approved package). 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.'
parameters:
- description: Install with approval
in: body