Files
LocalAI/core/application/addon_hostgrants_test.go
T
Ettore Di Giacinto 3ca176d15d feat(addons): lock UI host grants while sign-in is off
With authentication off every caller that reaches the port is the same
local owner, and the checks that tell a person from a script are
headers the caller chooses. Any process or agent that could reach the
port could grant host operations, set deny rules and turn the blanket
approval on.

UI host grants are now locked by default in that mode. The policy file
and flags still grant, and every write route answers 403
locked_by_deployment with a sentence that says why. The overview and the
preview carry the lock and its reason for the page. The new flag
--addon-host-ui-grants-without-auth turns the UI grants on knowingly and
logs a startup warning. With sign-in on nothing changes.

Assisted-by: Claude:claude-sonnet-5-5 go gofmt
2026-11-03 22:52:09 +00:00

431 lines
21 KiB
Go

// SPDX-License-Identifier: MIT
package application
import (
"archive/tar"
"bytes"
"context"
"crypto/sha256"
"encoding/json"
"errors"
"fmt"
"net"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strconv"
"time"
"github.com/mudler/LocalAI/core/config"
"github.com/mudler/LocalAI/core/http/auth"
"github.com/mudler/LocalAI/core/services/addons"
"github.com/mudler/LocalAI/core/services/addons/catalog"
"github.com/mudler/LocalAI/core/services/addons/gateway"
"github.com/mudler/LocalAI/core/services/addons/hostops"
"github.com/mudler/LocalAI/pkg/addons/contract"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
// These specs run the owner the way local-ai run does, install a package
// through the real host access service and ask the Authority through a broker.
var _ = Describe("host grants on the owner", func() {
var (
ownerData string
keys string
port string
sourceURL string
digests map[string]string
actor = contract.Actor{UserID: auth.AddonLocalOwnerID, CredentialID: auth.AddonLocalOwnerID, Mode: auth.AddonModeLocalOwner}
)
pack := func(version string, required, optional []string) (data []byte) {
manifest, e := os.ReadFile("../../tests/addons/fixtures/packages/manifest.json")
ExpectWithOffset(1, e).NotTo(HaveOccurred())
var m map[string]any
ExpectWithOffset(1, json.Unmarshal(manifest, &m)).To(Succeed())
m["version"] = version
m["capabilities"] = map[string]any{"service": true, "hostOperations": map[string]any{"required": required, "optional": optional}}
manifest, _ = json.Marshal(m)
var b bytes.Buffer
tw := tar.NewWriter(&b)
for name, content := range map[string][]byte{"manifest.json": manifest, "bin/sample": []byte("binary " + version)} {
ExpectWithOffset(1, tw.WriteHeader(&tar.Header{Name: name, Mode: 0755, Size: int64(len(content))})).To(Succeed())
_, e = tw.Write(content)
ExpectWithOffset(1, e).NotTo(HaveOccurred())
}
ExpectWithOffset(1, tw.Close()).To(Succeed())
return b.Bytes()
}
BeforeEach(func() {
requireAddonRuntime()
keys = filepath.Join(realTempDir(), "keys")
Expect(gateway.GenerateCarrierKeys(keys, []string{"127.0.0.1"})).To(Succeed())
ownerData = realTempDir()
l, e := net.Listen("tcp", "127.0.0.1:0")
Expect(e).NotTo(HaveOccurred())
port = strconv.Itoa(l.Addr().(*net.TCPAddr).Port)
Expect(l.Close()).To(Succeed())
archives := map[string][]byte{
"/1.2.3.tar": pack("1.2.3", []string{"workspace.materialize"}, []string{"egress.check"}),
"/1.3.0.tar": pack("1.3.0", []string{"workspace.materialize", "egress.audit"}, []string{"egress.check"}),
}
digests = map[string]string{}
for path, data := range archives {
digests[path] = fmt.Sprintf("sha256:%x", sha256.Sum256(data))
}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if data, ok := archives[r.URL.Path]; ok {
_, _ = w.Write(data)
return
}
rel := func(v string) catalog.Release {
return catalog.Release{ID: "sample", Name: "Sample", Version: v, OS: "linux", Arch: "amd64", Artifact: sourceURL + "/" + v + ".tar", Digest: digests["/"+v+".tar"]}
}
_ = catalog.WriteIndex(w, []catalog.Release{rel("1.2.3"), rel("1.3.0")})
}))
DeferCleanup(server.Close)
sourceURL = server.URL
root := filepath.Join(ownerData, "addons")
DeferCleanup(func() {
_ = filepath.Walk(root, func(p string, i os.FileInfo, e error) error {
if e == nil && i.IsDir() {
_ = os.Chmod(p, 0700)
}
return nil
})
})
Expect(os.MkdirAll(root, 0700)).To(Succeed())
sources, e := catalog.NewSources(filepath.Join(root, "sources.json"), func(context.Context, contract.Actor) error { return nil }, catalog.CredentialResolver{})
Expect(e).NotTo(HaveOccurred())
Expect(sources.Save(context.Background(), actor, catalog.Source{ID: "test", Name: "Test", URL: sourceURL, Enabled: true, AllowLoopbackHTTP: true})).To(Succeed())
})
// startOwner builds the owner on the same data directory. Each start
// stands for one run of the process.
startOwner := func(opts ...func(*config.ApplicationConfig)) (*Application, func()) {
transport, e := gateway.LoadCarrierConfig(keys, "owner", "https://127.0.0.1:"+port)
Expect(e).NotTo(HaveOccurred())
ctx, cancel := context.WithCancel(context.Background())
// The suite runs in local-owner mode, where UI grants are locked unless the
// administrator turns them on knowingly. Most specs test the grants.
cfg := &config.ApplicationConfig{Context: ctx, DataPath: ownerData, AddonHostUIGrantsWithoutAuth: true}
for _, o := range opts {
o(cfg)
}
cfg.Addons = config.AddonConfig{
Enabled: true,
InstanceID: transport.InstanceID, OwnerID: gateway.OwnerIdentity, OwnerAddress: "https://127.0.0.1:" + port,
StorageID: "local", Root: filepath.Join(ownerData, "addons"), Transport: transport, OwnershipMode: auth.AddonModeLocalOwner,
Host: contract.HostCompatibility{Host: 1, API: contract.HostAPIVersion, Protocol: 1, SDK: 1, DataFormat: 1, OS: "linux", Arch: "amd64"},
ListenAddress: "127.0.0.1:" + port,
}
a := &Application{applicationConfig: cfg}
stopped := false
stop := func() {
if !stopped {
stopped = true
cancel()
_ = a.Shutdown()
}
}
if e = a.startAddons(); e != nil {
stop()
Fail("owner did not start: " + e.Error())
}
DeferCleanup(stop)
return a, stop
}
human := func() context.Context { return gateway.WithHumanSurface(context.Background()) }
call := func(a *Application, c gateway.HostGrantsCall) (map[string]any, error) {
raw, e := a.AddonGateway().HostGrants(human(), actor, c)
if e != nil {
return nil, e
}
out := map[string]any{}
ExpectWithOffset(1, json.Unmarshal(raw, &out)).To(Succeed())
return out, nil
}
body := func(v any) json.RawMessage {
b, e := json.Marshal(v)
ExpectWithOffset(1, e).NotTo(HaveOccurred())
return b
}
revision := func(a *Application) uint64 {
out, e := call(a, gateway.HostGrantsCall{Op: gateway.HostGrantsRead})
ExpectWithOffset(1, e).NotTo(HaveOccurred())
return uint64(out["revision"].(float64))
}
// install previews a release and installs it with the approval of ops.
install := func(a *Application, opID, action, version string, ops []string, expected string) error {
pv, e := call(a, gateway.HostGrantsCall{Op: gateway.HostGrantsPreview, Body: body(gateway.HostGrantsPreviewRequest{SourceID: "test", AddonID: "sample", Version: version})})
ExpectWithOffset(1, e).NotTo(HaveOccurred())
req := gateway.HostGrantsInstallRequest{OperationID: opID, Action: action, SourceID: "test", AddonID: "sample", Version: version,
Approve: gateway.HostGrantsApprove{Digest: pv["digest"].(string), Ops: ops, Revision: uint64(pv["revision"].(float64)), Nonce: pv["nonce"].(string)}}
if expected != "" {
req.ExpectedDigest, req.CandidateDigest = expected, pv["digest"].(string)
}
_, e = call(a, gateway.HostGrantsCall{Op: gateway.HostGrantsInstall, OperationID: opID, Body: body(req)})
return e
}
// ask makes a host operation call the way the broker serves it, with the
// Authority of the application.
ask := func(a *Application, op string) int {
in := hostops.NewInFlight()
b := hostops.New(in, func(string, string) bool { return true }, contract.DefaultLimits())
ExpectWithOffset(1, b.Register(hostops.Spec{Op: op, Scope: "read", Feature: "x", Handler: func(context.Context, hostops.Call) (json.RawMessage, error) {
return json.RawMessage(`{"ok":true}`), nil
}})).To(Succeed())
h := b.HandlerForRuntime("sample", "secret", a.addonAuthority(), 0, "")
end := in.Begin(contract.Envelope{
Request: contract.Request{AddonID: "sample", OperationID: "op1", Generation: 1, Action: "start", Arguments: json.RawMessage(`{}`)},
Authority: contract.Authority{Actor: contract.Actor{UserID: "u1", CredentialID: "c1", Mode: "named-user"}, Scope: "read", ExpiresAt: time.Now().Add(time.Minute)},
})
defer end()
raw, _ := json.Marshal(contract.HostRequest{OperationID: "op1", Op: op, Arguments: json.RawMessage(`{}`)})
req := httptest.NewRequest(http.MethodPost, "/host", bytes.NewReader(raw))
req.Header.Set("X-Addon-Host-Credential", "secret")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
return rec.Code
}
submit := func(a *Application, opID, action, version, expected, candidate string) error {
_, e := a.addonManager.Submit(context.Background(), actor, addons.LifecycleCommand{OperationID: opID, AddonID: "sample", Action: action, SourceID: "test", Version: version, ExpectedDigest: expected, CandidateDigest: candidate})
return e
}
It("grants at install, revokes and denies without a restart, and keeps the grants across restarts", func() {
a, stop := startOwner()
Expect(a.AddonGateway().HostGrantsOwner).NotTo(BeNil())
// Nothing approved: the install is refused and says what to approve.
pv, e := call(a, gateway.HostGrantsCall{Op: gateway.HostGrantsPreview, Body: body(gateway.HostGrantsPreviewRequest{SourceID: "test", AddonID: "sample", Version: "1.2.3"})})
Expect(e).NotTo(HaveOccurred())
Expect(pv["canInstall"]).To(BeTrue())
Expect(install(a, "i-none", "install", "1.2.3", nil, "")).To(HaveOccurred())
Expect(ask(a, "workspace.materialize")).To(Equal(http.StatusForbidden))
// Approved: the required operation works, the unticked optional one does not.
Expect(install(a, "i-1", "install", "1.2.3", []string{"workspace.materialize"}, "")).To(Succeed())
Expect(ask(a, "workspace.materialize")).To(Equal(http.StatusOK))
Expect(ask(a, "egress.check")).To(Equal(http.StatusForbidden))
Expect(a.addonAuthority().Snapshot("sample").Operations).To(Equal([]string{"workspace.materialize"}))
// Revoke: the very next call is refused, with no restart.
_, e = call(a, gateway.HostGrantsCall{Op: gateway.HostGrantsRevoke, AddonID: "sample", OperationID: "r-1", Body: body(gateway.HostGrantsRevokeRequest{OperationID: "r-1", Revision: revision(a)})})
Expect(e).NotTo(HaveOccurred())
Expect(ask(a, "workspace.materialize")).To(Equal(http.StatusForbidden))
// Edit gives it back; a dry run of a deny names who loses what and writes nothing.
_, e = call(a, gateway.HostGrantsCall{Op: gateway.HostGrantsEdit, AddonID: "sample", OperationID: "e-1", Body: body(gateway.HostGrantsEditRequest{OperationID: "e-1", Revision: revision(a), Ops: []string{"workspace.materialize", "egress.check"}})})
Expect(e).NotTo(HaveOccurred())
Expect(ask(a, "egress.check")).To(Equal(http.StatusOK))
before := revision(a)
dry, e := call(a, gateway.HostGrantsCall{Op: gateway.HostGrantsDenyPreview, Body: body(gateway.HostGrantsDenyRequest{Revision: before, Deny: map[string][]string{"sample": {"egress."}}, DryRun: true})})
Expect(e).NotTo(HaveOccurred())
Expect(dry["affects"]).NotTo(BeEmpty())
Expect(revision(a)).To(Equal(before))
Expect(ask(a, "egress.check")).To(Equal(http.StatusOK))
// A deny set in the UI beats the grant at once; removing it restores the grant.
_, e = call(a, gateway.HostGrantsCall{Op: gateway.HostGrantsDeny, OperationID: "d-1", Body: body(gateway.HostGrantsDenyRequest{OperationID: "d-1", Revision: before, Deny: map[string][]string{"sample": {"egress."}}})})
Expect(e).NotTo(HaveOccurred())
Expect(ask(a, "egress.check")).To(Equal(http.StatusForbidden))
Expect(ask(a, "workspace.materialize")).To(Equal(http.StatusOK))
_, e = call(a, gateway.HostGrantsCall{Op: gateway.HostGrantsDeny, OperationID: "d-2", Body: body(gateway.HostGrantsDenyRequest{OperationID: "d-2", Revision: revision(a), Deny: map[string][]string{}})})
Expect(e).NotTo(HaveOccurred())
Expect(ask(a, "egress.check")).To(Equal(http.StatusOK))
// The audit file records the changes and names no path or secret.
audit, e := os.ReadFile(filepath.Join(ownerData, "addons", addonHostGrantsAudit))
Expect(e).NotTo(HaveOccurred())
Expect(string(audit)).To(ContainSubstring(`"event":"grant"`))
Expect(string(audit)).To(ContainSubstring(`"event":"revoke"`))
Expect(string(audit)).NotTo(ContainSubstring(ownerData))
// A restart reopens the document: the grants are still there.
stop()
again, _ := startOwner()
Expect(ask(again, "workspace.materialize")).To(Equal(http.StatusOK))
Expect(ask(again, "egress.check")).To(Equal(http.StatusOK))
})
It("lets a deny of the deployment beat a grant of the UI", func() {
a, stop := startOwner()
Expect(install(a, "i-1", "install", "1.2.3", []string{"workspace.materialize"}, "")).To(Succeed())
Expect(ask(a, "workspace.materialize")).To(Equal(http.StatusOK))
stop()
denied, stopDenied := startOwner(func(c *config.ApplicationConfig) {
c.AddonHostDeny = map[string][]string{"*": {"workspace."}}
})
Expect(ask(denied, "workspace.materialize")).To(Equal(http.StatusForbidden))
stopDenied()
// The grant was not lost: without the deny it works again.
last, _ := startOwner()
Expect(ask(last, "workspace.materialize")).To(Equal(http.StatusOK))
})
It("refuses everything when the document cannot be read", func() {
Expect(os.MkdirAll(filepath.Join(ownerData, "addons"), 0700)).To(Succeed())
Expect(os.WriteFile(filepath.Join(ownerData, "addons", addonHostGrantsFile), []byte("{not json"), 0600)).To(Succeed())
a, _ := startOwner(func(c *config.ApplicationConfig) {
c.AddonHostOps = map[string][]string{"sample": {"workspace."}}
})
Expect(ask(a, "workspace.materialize")).To(Equal(http.StatusForbidden), "the deny rules in the document are unknown")
Expect(a.AddonHostGrantsStore()).To(BeNil())
})
It("holds an upgrade that asks for more and keeps the old version running", func() {
a, _ := startOwner()
Expect(install(a, "i-1", "install", "1.2.3", []string{"workspace.materialize"}, "")).To(Succeed())
old := digests["/1.2.3.tar"]
newer := digests["/1.3.0.tar"]
// Without an approval the upgrade is refused and nothing changes.
e := submit(a, "u-0", "upgrade", "1.3.0", old, newer)
var refusal *addons.HostRequirementsRefusal
Expect(e).To(HaveOccurred())
Expect(errors.As(e, &refusal)).To(BeTrue(), e.Error())
st, e := a.addonManager.Status(context.Background(), "sample")
Expect(e).NotTo(HaveOccurred())
Expect(st.Generation).To(Equal(uint64(1)))
Expect(ask(a, "workspace.materialize")).To(Equal(http.StatusOK), "the old version keeps its grant")
Expect(ask(a, "egress.audit")).To(Equal(http.StatusForbidden))
// Approved in the browser: the new operation counts once the upgrade completed.
Expect(install(a, "u-1", "upgrade", "1.3.0", []string{"egress.audit"}, old)).To(Succeed())
st, _ = a.addonManager.Status(context.Background(), "sample")
Expect(st.Generation).To(Equal(uint64(2)))
Expect(ask(a, "workspace.materialize")).To(Equal(http.StatusOK))
Expect(ask(a, "egress.audit")).To(Equal(http.StatusOK))
})
It("applies the blanket mode a deployment fixes without a dialog, and records it", func() {
a, _ := startOwner(func(c *config.ApplicationConfig) { c.AddonHostBlanket = "any" })
Expect(submit(a, "b-1", "install", "1.2.3", "", "")).To(Succeed())
// Required operations are granted; the optional one needs includeOptional.
Expect(ask(a, "workspace.materialize")).To(Equal(http.StatusOK))
Expect(ask(a, "egress.check")).To(Equal(http.StatusForbidden))
audit, e := os.ReadFile(filepath.Join(ownerData, "addons", addonHostGrantsAudit))
Expect(e).NotTo(HaveOccurred())
Expect(string(audit)).To(ContainSubstring(`"event":"blanket-grant"`))
Expect(string(audit)).To(ContainSubstring(`"blanket":"any@rev0"`))
})
It("never copies a blanket mode a deployment fixes into the document", func() {
a, stop := startOwner(func(c *config.ApplicationConfig) { c.AddonHostBlanket = "any" })
Expect(submit(a, "b-1", "install", "1.2.3", "", "")).To(Succeed())
out, e := call(a, gateway.HostGrantsCall{Op: gateway.HostGrantsRead})
Expect(e).NotTo(HaveOccurred())
blanket := out["blanket"].(map[string]any)
Expect(blanket["mode"]).To(Equal("any"))
Expect(blanket["setBy"]).To(Equal("deployment"))
doc, e := os.ReadFile(filepath.Join(ownerData, "addons", addonHostGrantsFile))
Expect(e).NotTo(HaveOccurred())
Expect(string(doc)).NotTo(ContainSubstring(`"any"`), "the deployment value must not reach the document")
// Restart without the flag: the switch is what a person set, which is off.
stop()
again, stopAgain := startOwner()
out, e = call(again, gateway.HostGrantsCall{Op: gateway.HostGrantsRead})
Expect(e).NotTo(HaveOccurred())
blanket = out["blanket"].(map[string]any)
Expect(blanket["mode"]).To(Equal("off"))
Expect(blanket["setBy"]).To(Equal("ui"))
// An upgrade that needs more is held again, as nobody typed the confirmation.
e = submit(again, "u-0", "upgrade", "1.3.0", digests["/1.2.3.tar"], digests["/1.3.0.tar"])
var refusal *addons.HostRequirementsRefusal
Expect(errors.As(e, &refusal)).To(BeTrue(), fmt.Sprint(e))
stopAgain()
// A flag set again takes effect with no stored copy.
fixed, _ := startOwner(func(c *config.ApplicationConfig) { c.AddonHostBlanket = "any" })
Expect(submit(fixed, "u-1", "upgrade", "1.3.0", digests["/1.2.3.tar"], digests["/1.3.0.tar"])).To(Succeed())
Expect(ask(fixed, "egress.audit")).To(Equal(http.StatusOK))
})
It("refuses an install and leaves the document unchanged when the audit line cannot be written", func() {
a, _ := startOwner()
before := revision(a)
Expect(a.addonGrants.audit.Close()).To(Succeed())
Expect(install(a, "i-1", "install", "1.2.3", []string{"workspace.materialize"}, "")).NotTo(Succeed())
Expect(revision(a)).To(Equal(before))
Expect(ask(a, "workspace.materialize")).To(Equal(http.StatusForbidden))
st, e := a.addonManager.Status(context.Background(), "sample")
Expect(e).NotTo(HaveOccurred())
Expect(st.Installed).To(BeFalse())
})
It("locks the UI grants while sign-in is off, and opens them only when the administrator says so", func() {
a, stop := startOwner(func(c *config.ApplicationConfig) {
c.AddonHostUIGrantsWithoutAuth = false
c.AddonHostOps = map[string][]string{"sample": {"workspace."}}
})
out, e := call(a, gateway.HostGrantsCall{Op: gateway.HostGrantsRead})
Expect(e).NotTo(HaveOccurred())
Expect(out["locked"]).To(BeTrue())
Expect(out["lockedReason"]).To(Equal(gateway.LockedNoAuth))
// Every write is refused with the typed code, and nothing is stored.
before := revision(a)
for _, c := range []gateway.HostGrantsCall{
{Op: gateway.HostGrantsEdit, AddonID: "sample", OperationID: "e-1", Body: body(gateway.HostGrantsEditRequest{OperationID: "e-1", Ops: []string{"egress.check"}})},
{Op: gateway.HostGrantsRevoke, AddonID: "sample", OperationID: "r-1", Body: body(gateway.HostGrantsRevokeRequest{OperationID: "r-1"})},
{Op: gateway.HostGrantsDeny, OperationID: "d-1", Body: body(gateway.HostGrantsDenyRequest{OperationID: "d-1", Deny: map[string][]string{"sample": {"egress."}}})},
{Op: gateway.HostGrantsBlanket, OperationID: "b-1", Body: body(gateway.HostGrantsBlanketRequest{OperationID: "b-1", Mode: "any", Confirm: gateway.BlanketConfirmAny})},
} {
_, e = call(a, c)
var he *gateway.HostGrantsError
Expect(errors.As(e, &he)).To(BeTrue(), c.Op)
Expect(he.Type).To(Equal(gateway.HostGrantsLocked), c.Op)
Expect(he.Message).To(ContainSubstring("Sign-in is off"))
}
Expect(revision(a)).To(Equal(before))
// The preview tells the install dialog, and the policy file still grants.
pv, e := call(a, gateway.HostGrantsCall{Op: gateway.HostGrantsPreview, Body: body(gateway.HostGrantsPreviewRequest{SourceID: "test", AddonID: "sample", Version: "1.2.3"})})
Expect(e).NotTo(HaveOccurred())
Expect(pv["locked"]).To(BeTrue())
Expect(submit(a, "i-1", "install", "1.2.3", "", "")).To(Succeed())
Expect(ask(a, "workspace.materialize")).To(Equal(http.StatusOK))
stop()
// Turned on knowingly, the same server accepts the write.
open, _ := startOwner()
out, e = call(open, gateway.HostGrantsCall{Op: gateway.HostGrantsRead})
Expect(e).NotTo(HaveOccurred())
Expect(out["locked"]).To(BeFalse())
_, e = call(open, gateway.HostGrantsCall{Op: gateway.HostGrantsEdit, AddonID: "sample", OperationID: "e-2", Body: body(gateway.HostGrantsEditRequest{OperationID: "e-2", Revision: revision(open), Ops: []string{"egress.check"}})})
Expect(e).NotTo(HaveOccurred())
})
It("keeps the grants of a split deployment on the owner only", func() {
a, _ := startOwner()
Expect(install(a, "i-1", "install", "1.2.3", []string{"workspace.materialize"}, "")).To(Succeed())
transport, e := gateway.LoadCarrierConfig(keys, "frontend", "https://127.0.0.1:"+port)
Expect(e).NotTo(HaveOccurred())
ctx, cancel := context.WithCancel(context.Background())
DeferCleanup(cancel)
frontendData := realTempDir()
f := &Application{applicationConfig: &config.ApplicationConfig{Context: ctx, DataPath: frontendData,
AddonHostOps: map[string][]string{"sample": {"egress."}}}}
f.applicationConfig.Addons = config.AddonConfig{Enabled: true, InstanceID: transport.InstanceID, OwnerID: gateway.OwnerIdentity,
OwnerAddress: "https://127.0.0.1:" + port, StorageID: "local", Transport: transport, OwnershipMode: auth.AddonModeLocalOwner,
Host: contract.HostCompatibility{Host: 1, API: contract.HostAPIVersion, Protocol: 1, SDK: 1, DataFormat: 1, OS: "linux", Arch: "amd64"}}
Expect(f.startAddons()).To(Succeed())
DeferCleanup(func() { _ = f.Shutdown() })
// The frontend stores nothing and enforces only its own floor, never the grants of the owner.
Expect(f.AddonHostGrantsStore()).To(BeNil())
Expect(f.AddonGateway().HostGrantsOwner).To(BeNil())
Expect(ask(f, "workspace.materialize")).To(Equal(http.StatusForbidden))
entries, e := os.ReadDir(frontendData)
Expect(e).NotTo(HaveOccurred())
Expect(entries).To(BeEmpty())
// Reading the owner's grants through the frontend works, and writes forward.
out, e := call(f, gateway.HostGrantsCall{Op: gateway.HostGrantsRead})
Expect(e).NotTo(HaveOccurred())
Expect(out["revision"]).NotTo(BeZero())
})
})