feat(addons): add the owner service and split forwarding for host access

The owner now serves the host access calls: read the grants, preview what an
install would ask for, install or upgrade with an approval, edit or revoke
the grants of one add-on, change the blanket approval and the deny rules.
Every write needs the human surface mark and is refused when the deployment
locks the store. Typed refusals (revision_conflict, approval_stale,
blocked_by_policy, locked_by_deployment and others) cross the carrier
unchanged, so every frontend gives the answer of the owner.

A frontend carries the mark as a signed field of the forwarded request, and
only for a host access call. The owner honors it and nothing else: a
frontend with its own flags or store still forwards, and an owner without the
calls is reported as unavailable with its reason.

The preview issues a single-use nonce bound to the credential, the add-on,
the package digest and the grants revision. The install must present it.

Assisted-by: Claude:claude-sonnet-5-5 go gofmt ginkgo
This commit is contained in:
Ettore Di Giacinto committed 2026-11-03 21:55:38 +00:00
1 parent 4603235385
commit 18e667cafb
10 files changed
+2125 -16

No files matched your search

+23 -2
View File
@@ -147,6 +147,9 @@ type wireRequest struct {
// Library marks a library call: a change of the library sources, or the
// owner's half of a backend install. Such a call is never a typed dispatch.
Library *LibraryCall `json:"library,omitempty"`
// HostGrants marks a host access call (grants, deny rules, blanket
// approval). Such a call is never a typed dispatch.
HostGrants *HostGrantsCall `json:"hostGrants,omitempty"`
}
// exclusive reports whether exactly one routing payload is set. Each branch of
@@ -155,7 +158,7 @@ type wireRequest struct {
// claims.
func (w wireRequest) exclusive() bool {
set := 0
for _, present := range []bool{w.ExecutionBinding != nil, w.Observation != nil, w.Relay != nil, w.Proposal != nil, w.Artifact != nil, w.Apps != nil, w.Resource != nil, w.Library != nil} {
for _, present := range []bool{w.ExecutionBinding != nil, w.Observation != nil, w.Relay != nil, w.Proposal != nil, w.Artifact != nil, w.Apps != nil, w.Resource != nil, w.Library != nil, w.HostGrants != nil} {
if present {
set++
}
@@ -328,6 +331,22 @@ func (c *Carrier) OwnerHandler(g *Gateway) (http.Handler, error) {
_ = json.NewEncoder(w).Encode(out)
return
}
if wire.HostGrants != nil {
call := *wire.HostGrants
// The owner classifies by the call, not by the action the peer wrote.
if !wire.exclusive() || !call.valid() || wire.Request.Action != call.wireAction() {
http.Error(w, "invalid binding", 400)
return
}
// The human mark is the signed field of this request, honored for
// a host access call only.
hctx := r.Context()
if m.HumanSurface {
hctx = WithHumanSurface(hctx)
}
g.hostGrantsLocalReply(hctx, w, actor, call)
return
}
if wire.Library != nil {
call := *wire.Library
// The owner classifies by the call, not by the action the peer wrote.
@@ -757,6 +776,8 @@ func (c *Carrier) send(ctx context.Context, a contract.Actor, wire wireRequest)
if b, ok := hostops.BindingOf(ctx); ok {
m.Chat = &b
}
// Only a host access call carries the mark, whatever the context holds.
m.HumanSurface = wire.HostGrants != nil && HumanSurfaceOf(ctx)
m.Sign(c.config.SigningKey)
b, _ := json.Marshal(m)
req, e := http.NewRequestWithContext(ctx, "POST", c.config.OwnerAddress+"/addons/forward", bytes.NewReader(b))
@@ -871,7 +892,7 @@ var resourceRead = map[string]bool{"resource-list": true, "resource-grants": tru
// mutation fails closed: only the known read actions are exempt, so an action
// added later is reported as an unknown outcome until it is classified.
func mutation(action string) bool {
return action != ActionHostSettingsRead && action != executionBindingAction && action != executionStatusAction && action != "read" && action != "operation-status" && action != "proposal-get" && action != "proposal-reconcile" && action != "proposal-list" && action != "artifact-status" && action != appsListAction && !readManagement[action] && !resourceRead[action] && !libraryRead[action]
return action != ActionHostSettingsRead && action != executionBindingAction && action != executionStatusAction && action != "read" && action != "operation-status" && action != "proposal-get" && action != "proposal-reconcile" && action != "proposal-list" && action != "artifact-status" && action != appsListAction && !readManagement[action] && !resourceRead[action] && !libraryRead[action] && !hostGrantsRead[action]
}
// Act forwards an action call to the owner and waits for its answer, up to the
+7 -2
View File
@@ -38,8 +38,13 @@ type ForwardedRequest struct {
// with the chat's access. It is never put in the envelope the add-on
// receives. Nil (omitted) for a call that no chat made, so such a
// request signs as before.
Chat *hostops.Binding `json:"chat,omitempty"`
Signature []byte `json:"signature"`
Chat *hostops.Binding `json:"chat,omitempty"`
// HumanSurface says the receiving frontend found the request to come from
// the administrator's own browser tab (see WithHumanSurface). It is
// covered by the signature and the owner honors it only for a host access
// call. Omitted when false, so other requests sign as before.
HumanSurface bool `json:"humanSurface,omitempty"`
Signature []byte `json:"signature"`
}
// Binding returns the chat binding of the request. Call it only on a request
+4
View File
@@ -68,6 +68,10 @@ type Gateway struct {
// Libraries is the owner's library administration. Frontends leave it nil
// and forward through Forward.
Libraries LibraryAdmin
// HostGrantsOwner serves the host access routes (grants, deny rules and
// the blanket approval). Only the owner holds it: a frontend leaves it
// nil and forwards, and never answers from its own flags or files.
HostGrantsOwner *HostGrantsOwner
// ModelsProbe returns the probe the owner wrote into its models path, or
// false when it has none. Frontends leave it nil.
ModelsProbe func() (modelsprobe.Probe, bool)
+530
View File
@@ -0,0 +1,530 @@
// SPDX-License-Identifier: MIT
package gateway
import (
"bytes"
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"io"
"net/http"
"github.com/mudler/LocalAI/core/services/addons"
"github.com/mudler/LocalAI/pkg/addons/contract"
)
// The operations of a host grants call. Each one is a carrier verb with the
// action "hostgrants-<op>". Read, preview and deny-preview change nothing
// durable, so a reply lost on the way is plainly unavailable. Every other
// verb is a mutation: a lost reply is an unknown outcome with its operation ID.
const (
HostGrantsRead = "read"
HostGrantsPreview = "preview"
HostGrantsInstall = "install"
HostGrantsEdit = "edit"
HostGrantsRevoke = "revoke"
HostGrantsBlanket = "blanket"
HostGrantsDeny = "deny"
HostGrantsDenyPreview = "deny-preview"
)
// CallHostGrants is the capability of an owner that serves the host grants
// calls. A frontend asks for it before it forwards, so an older owner is
// reported as unsupported and not as an unknown outcome.
const CallHostGrants = "host-grants"
// ReasonHostGrantsUnsupported is the 503 text for an owner without the calls.
const ReasonHostGrantsUnsupported = "the add-on owner does not support host access grants; upgrade the owner"
// HostGrantsCall is one host grants request to the owner. Body is the typed
// request of the operation; the owner decodes it again, strictly.
type HostGrantsCall struct {
Op string `json:"op"`
// OperationID is the idempotency key of a write. A read has none.
OperationID string `json:"operationId,omitempty"`
AddonID string `json:"addonId,omitempty"`
Body json.RawMessage `json:"body,omitempty"`
}
func (c HostGrantsCall) wireAction() string { return "hostgrants-" + c.Op }
func (c HostGrantsCall) write() bool {
switch c.Op {
case HostGrantsInstall, HostGrantsEdit, HostGrantsRevoke, HostGrantsBlanket, HostGrantsDeny:
return true
}
return false
}
func (c HostGrantsCall) valid() bool {
switch c.Op {
case HostGrantsRead, HostGrantsPreview, HostGrantsDenyPreview:
return c.OperationID == ""
case HostGrantsInstall, HostGrantsEdit, HostGrantsRevoke, HostGrantsBlanket, HostGrantsDeny:
return c.OperationID != "" && len(c.OperationID) <= 128
}
return false
}
// hostGrantsRead are the host grants actions that change nothing durable.
var hostGrantsRead = map[string]bool{
"hostgrants-" + HostGrantsRead: true, "hostgrants-" + HostGrantsPreview: true, "hostgrants-" + HostGrantsDenyPreview: true,
}
// The error types of a HostGrantsError. They are the wire types, so every
// frontend gives the answer of the owner.
const (
HostGrantsNotHumanSurface = "not_human_surface"
HostGrantsLocked = "locked_by_deployment"
HostGrantsBlockedByPolicy = "blocked_by_policy"
HostGrantsApprovalRequired = "approval_required"
HostGrantsApprovalStale = "approval_stale"
HostGrantsRevisionConflict = "revision_conflict"
HostGrantsOperationConflict = "operation_conflict"
HostGrantsInvalidRequest = "invalid_request"
HostGrantsNotFound = "not_found"
HostGrantsConfirmation = "confirmation_mismatch"
)
// HostGrantsError is a refusal the owner decided. It travels in the reply
// envelope with status 200, because typedError would map several of these
// to other statuses than the routes answer.
type HostGrantsError struct {
Type string `json:"type"`
Message string `json:"message"`
Status int `json:"status"`
}
func (e *HostGrantsError) Error() string { return e.Message }
// Errors a HostAccessManager returns for a decision of the lifecycle. The
// owner service types them for the routes.
var (
// ErrApprovalStale means the package staged for the install is not the
// one that was approved.
ErrApprovalStale = errors.New("host access approval is stale")
// ErrBlockedByPolicy means a required operation is denied by a policy.
ErrBlockedByPolicy = errors.New("host access blocked by policy")
// ErrApprovalRequired means an operation that is required is neither
// held nor approved.
ErrApprovalRequired = errors.New("host access approval required")
)
func hgErr(status int, kind, msg string) *HostGrantsError {
return &HostGrantsError{Type: kind, Message: msg, Status: status}
}
type hostGrantsReply struct {
Data json.RawMessage `json:"data,omitempty"`
Error *HostGrantsError `json:"error,omitempty"`
}
type humanSurfaceKey struct{}
// WithHumanSurface marks a context as that of a request the HTTP layer found
// to come from the administrator's own browser tab. Only the HTTP layer of
// the process that received the request may set it. A frontend carries it to
// the owner as a signed field of the forwarded request; the owner sets it
// from the verified field. Without it every host grants write is refused.
func WithHumanSurface(ctx context.Context) context.Context {
return context.WithValue(ctx, humanSurfaceKey{}, true)
}
// HumanSurfaceOf reports whether the context carries the mark.
func HumanSurfaceOf(ctx context.Context) bool {
v, _ := ctx.Value(humanSurfaceKey{}).(bool)
return v
}
// HostGrants runs one host grants call: on the owner itself, or through the
// carrier. A frontend never answers from its own flags or files.
func (g *Gateway) HostGrants(ctx context.Context, a contract.Actor, call HostGrantsCall) (json.RawMessage, error) {
if !call.valid() {
return nil, ErrForbidden
}
if e := g.revalidate(ctx, a); e != nil {
return nil, e
}
if g.Forward != nil {
return g.Forward.HostGrants(ctx, a, call)
}
return g.hostGrantsLocal(ctx, a, call)
}
func (g *Gateway) hostGrantsLocal(ctx context.Context, a contract.Actor, call HostGrantsCall) (json.RawMessage, error) {
if g.HostGrantsOwner == nil {
return nil, ErrUnavailable
}
return g.HostGrantsOwner.Serve(ctx, a, call)
}
// strictBody decodes a body that must hold exactly one JSON object of the
// type of dst, with no unknown field.
func strictBody(raw json.RawMessage, dst any) bool {
if len(raw) == 0 {
raw = json.RawMessage(`{}`)
}
d := json.NewDecoder(bytes.NewReader(raw))
d.DisallowUnknownFields()
if d.Decode(dst) != nil {
return false
}
_, err := d.Token()
return err == io.EOF
}
func randomHex(n int) (string, error) {
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
return "", err
}
return hex.EncodeToString(b), nil
}
// HostApproval is what the administrator approved in the install dialog. The
// owner recomputes everything it names; the manager grants only
// approved, declared by the verified manifest and registered operations.
type HostApproval struct {
Digest string
Ops []string
Revision uint64
Nonce string
}
// ReleaseAccess is what the verified, staged package of a release asks for.
type ReleaseAccess struct {
AddonID, Title, Version string
// Digest is the digest of the staged package.
Digest string
// Official is true only when the package comes from the built-in official
// library and its receipt shows the official signer.
Official bool
Required, Optional []string
// Installed is true when the add-on is installed already, so the release
// would be an upgrade.
Installed bool
}
// PendingUpgrade is a candidate release that waits for approval.
type PendingUpgrade struct {
Version string `json:"version"`
Delta []string `json:"delta"`
}
// InstalledAccess is an installed add-on as the host access page needs it.
type InstalledAccess struct {
AddonID, Title, Version string
// State is the state the Apps list shows.
State string
// Identity is the trusted identity of the installed package.
Identity string
// Generation and OperationID name the installed generation and the
// lifecycle operation that completed it. A new UI entry of an add-on
// that has none takes them, so the entry counts at once.
Generation uint64
OperationID string
Official bool
Required, Optional []string
PendingUpgrade *PendingUpgrade
}
// HostAccessManager is what the owner service needs from the add-on manager.
// The manager implements it; the install and upgrade approval runs inside the
// manager, between the real Stage and the admission, so the package it checks
// is the package it installs.
type HostAccessManager interface {
// HostAccess lists the installed add-ons with their verified requests.
// It answers ErrRecoveryRequired while the owner recovers.
HostAccess(ctx context.Context) ([]InstalledAccess, error)
// PreviewHostAccess resolves and stages a release, reads its verified
// manifest and discards the stage. It changes no state.
PreviewHostAccess(ctx context.Context, sourceID, addonID, version string) (ReleaseAccess, error)
// SubmitWithApproval runs the install (or the pinned upgrade, for
// cmd.Action upgrade) with the approval attached.
SubmitWithApproval(ctx context.Context, actor contract.Actor, cmd addons.LifecycleCommand, approval HostApproval) (addons.OperationStatus, error)
}
// Wire shapes. Field names are the API of the routes.
// HostGrantsPreviewRequest is the body of POST /api/addons/host-grants/preview.
type HostGrantsPreviewRequest struct {
SourceID string `json:"sourceId"`
AddonID string `json:"addonId"`
Version string `json:"version"`
}
// HostGrantsApprove is what the administrator saw and approved.
type HostGrantsApprove struct {
Digest string `json:"digest"`
Ops []string `json:"ops"`
Revision uint64 `json:"revision"`
Nonce string `json:"nonce"`
}
// HostGrantsInstallRequest is the body of POST /api/addons/host-grants/install.
type HostGrantsInstallRequest struct {
OperationID string `json:"operationId"`
// Action is install or upgrade.
Action string `json:"action"`
SourceID string `json:"sourceId"`
AddonID string `json:"addonId"`
Version string `json:"version"`
// ExpectedDigest and CandidateDigest pin an upgrade; both or neither.
ExpectedDigest string `json:"expectedDigest,omitempty"`
CandidateDigest string `json:"candidateDigest,omitempty"`
Approve HostGrantsApprove `json:"approve"`
}
// 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"`
}
// HostGrantsRevokeRequest is the body the route builds from the query of
// DELETE /api/addons/host-grants/{addonId}.
type HostGrantsRevokeRequest struct {
OperationID string `json:"operationId"`
Revision uint64 `json:"revision"`
}
// HostGrantsBlanketRequest is the body of PUT /api/addons/host-grants/blanket.
type HostGrantsBlanketRequest struct {
OperationID string `json:"operationId"`
Revision uint64 `json:"revision"`
Mode string `json:"mode"`
IncludeOptional bool `json:"includeOptional"`
ApplyToInstalled bool `json:"applyToInstalled"`
// RevokeOnOff, with mode off, also revokes the grants the blanket made.
RevokeOnOff bool `json:"revokeOnOff"`
// Confirm is the typed confirmation text, required to turn the switch
// on or widen it.
Confirm string `json:"confirm"`
}
// HostGrantsDenyRequest is the body of PUT /api/addons/host-grants/deny.
type HostGrantsDenyRequest struct {
OperationID string `json:"operationId"`
Revision uint64 `json:"revision"`
Deny map[string][]string `json:"deny"`
// DryRun answers what the rules would do and changes nothing.
DryRun bool `json:"dryRun,omitempty"`
}
// BlanketConfirmOfficial and BlanketConfirmAny are the texts the administrator
// types to turn the blanket approval on.
const (
BlanketConfirmOfficial = "grant official add-ons what they declare"
BlanketConfirmAny = "grant any library add-on what it declares"
)
// HostOpView is one operation of an add-on as the pages show it.
type HostOpView struct {
Name string `json:"name"`
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 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"`
}
// HostRequested lists what a manifest asks for.
type HostRequested struct {
Required []string `json:"required"`
Optional []string `json:"optional"`
}
// 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"`
}
// HostBlanketView is the blanket switch as the page shows it.
type HostBlanketView struct {
Mode string `json:"mode"`
IncludeOptional bool `json:"includeOptional"`
// SetBy is ui, or deployment when a flag fixes the mode.
SetBy string `json:"setBy"`
Revision uint64 `json:"revision"`
}
// HostFloorAddon is the deployment grant of one add-on.
type HostFloorAddon struct {
ID string `json:"id"`
Prefixes []string `json:"prefixes"`
}
// HostGrantsFloor is the read-only policy of the file and flags.
type HostGrantsFloor struct {
Source string `json:"source"`
Addons []HostFloorAddon `json:"addons"`
}
// HostGrantsDenyView holds the deny rules by where they come from.
type HostGrantsDenyView struct {
File map[string][]string `json:"file"`
UI map[string][]string `json:"ui"`
}
// HostGrantsView is the response of GET /api/addons/host-grants.
type HostGrantsView struct {
Revision uint64 `json:"revision"`
Locked bool `json:"locked"`
Blanket HostBlanketView `json:"blanket"`
Floor HostGrantsFloor `json:"floor"`
Deny HostGrantsDenyView `json:"deny"`
Addons []HostGrantsAddon `json:"addons"`
}
// HostBlanketPreview says whether the blanket switch would grant on install.
type HostBlanketPreview struct {
Applies bool `json:"applies"`
Revision uint64 `json:"revision,omitempty"`
}
// 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"`
Requested HostRequested `json:"requested"`
Ops []HostOpView `json:"ops"`
CanInstall bool `json:"canInstall"`
BlockedReq []string `json:"blockedRequired"`
Revision uint64 `json:"revision"`
Nonce string `json:"nonce"`
Blanket HostBlanketPreview `json:"blanket"`
NonceExpiry string `json:"nonceExpiresAt"`
}
// HostGrantsAddonResult is the response of an edit.
type HostGrantsAddonResult struct {
Revision uint64 `json:"revision"`
Addon *HostGrantsAddon `json:"addon,omitempty"`
}
// HostBlanketChange is one add-on a blanket change granted operations to.
type HostBlanketChange struct {
ID string `json:"id"`
Ops []string `json:"ops"`
}
// HostBlanketSkip is one add-on a blanket change could not grant.
type HostBlanketSkip struct {
ID string `json:"id"`
Reason string `json:"reason"`
}
// HostGrantsBlanketResult is the response of the blanket change.
type HostGrantsBlanketResult struct {
Revision uint64 `json:"revision"`
Blanket HostBlanketView `json:"blanket"`
Changed []HostBlanketChange `json:"changed"`
Skipped []HostBlanketSkip `json:"skipped"`
Revoked []string `json:"revoked"`
}
// HostDenyAffect says what a deny rule set takes from one add-on.
type HostDenyAffect struct {
ID string `json:"id"`
Loses []string `json:"loses"`
WillStop bool `json:"willStop"`
}
// HostGrantsDenyResult is the response of the deny change and its dry run.
type HostGrantsDenyResult struct {
Revision uint64 `json:"revision"`
Deny map[string][]string `json:"deny"`
Affects []HostDenyAffect `json:"affects"`
DryRun bool `json:"dryRun,omitempty"`
}
// HostGrantsRevision is the response of a revoke.
type HostGrantsRevision struct {
Revision uint64 `json:"revision"`
Changed bool `json:"changed"`
}
// hostGrantsLocalReply runs a call on the owner and writes the reply envelope.
func (g *Gateway) hostGrantsLocalReply(ctx context.Context, w http.ResponseWriter, a contract.Actor, call HostGrantsCall) {
raw, e := g.hostGrantsLocal(ctx, a, call)
var he *HostGrantsError
switch {
case e == nil:
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(hostGrantsReply{Data: raw})
case errors.As(e, &he):
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(hostGrantsReply{Error: he})
default:
typedError(w, e)
}
}
// HostGrants forwards one host access call to the owner and returns the data
// of its reply. A refusal the owner decided is a *HostGrantsError. An owner
// without the calls is reported as unavailable with its reason, never as an
// unknown outcome.
func (c *Carrier) HostGrants(ctx context.Context, a contract.Actor, call HostGrantsCall) (json.RawMessage, error) {
if !call.valid() {
return nil, ErrForbidden
}
caps, e := c.Capabilities(ctx)
if e != nil {
return nil, e
}
if !caps.Supports(CallHostGrants) {
return nil, &UnavailableError{Reason: ReasonHostGrantsUnsupported}
}
id := call.OperationID
if id == "" {
if id, e = newResourceOperationID(); e != nil {
return nil, ErrUnavailable
}
}
resp, e := c.send(ctx, a, wireRequest{Request: contract.Request{OperationID: id, Action: call.wireAction()}, HostGrants: &call})
if e != nil {
return nil, e
}
defer func() { _ = resp.Body.Close() }()
raw, e := io.ReadAll(io.LimitReader(resp.Body, 2<<20+1))
var reply hostGrantsReply
if e != nil || len(raw) > 2<<20 || decode(bytes.NewReader(raw), &reply) != nil {
if mutation(call.wireAction()) {
return nil, &OutcomeUnknown{OperationID: id}
}
return nil, ErrUnavailable
}
if reply.Error != nil {
return nil, reply.Error
}
return reply.Data, nil
}
@@ -0,0 +1,870 @@
// SPDX-License-Identifier: MIT
package gateway
import (
"context"
"encoding/json"
"errors"
"fmt"
"slices"
"sort"
"strings"
"sync"
"time"
"github.com/mudler/LocalAI/core/services/addons"
"github.com/mudler/LocalAI/core/services/addons/hostgrants"
"github.com/mudler/LocalAI/core/services/addons/hostops"
"github.com/mudler/LocalAI/pkg/addons/contract"
)
// nonceLife is how long a preview nonce stays valid.
const nonceLife = 5 * time.Minute
const maxNonces = 1024
// HostOpDescription is the plain text of one operation.
type HostOpDescription struct{ Title, Text, Risk string }
// HostGrantsConfig builds the owner service.
type HostGrantsConfig struct {
Store *hostgrants.Store
// Floor is the startup policy of the file and flags.
Floor hostgrants.Floor
Audit *hostgrants.Audit
Manager HostAccessManager
// Supported is the host registry vocabulary. Nil means
// hostops.SupportedOperations().
Supported []string
// Describe gives the text of an operation. Nil, or a miss, shows the name.
Describe func(op string) (HostOpDescription, bool)
// BlanketOverride is the mode a deployment fixes ("" lets the UI decide).
BlanketOverride string
// Locked refuses every write: the deployment keeps the file the only source.
Locked bool
// Completed reports whether a lifecycle operation completed. Nil counts
// every entry as completed.
Completed func(operationID string) bool
// Admin checks the current host authority of the actor. Nil refuses.
Admin func(context.Context, contract.Actor) error
// AfterChange runs after a write changed what add-ons may call, so the
// application can start an add-on that now holds an operation but has no
// host endpoint. Optional; it must not block.
AfterChange func(addonIDs []string)
Now func() time.Time
}
// HostGrantsOwner is the owner's service for the host access routes. It holds
// the nonces of the previews, applies the rules of the spec and writes the
// grants document. It exists on the owner only.
type HostGrantsOwner struct {
cfg HostGrantsConfig
nonces nonceStore
}
// NewHostGrantsOwner returns the owner service. A store and a manager are required.
func NewHostGrantsOwner(cfg HostGrantsConfig) (*HostGrantsOwner, error) {
if cfg.Store == nil || cfg.Manager == nil {
return nil, ErrUnavailable
}
if cfg.Supported == nil {
cfg.Supported = hostops.SupportedOperations()
}
if cfg.Now == nil {
cfg.Now = time.Now
}
return &HostGrantsOwner{cfg: cfg, nonces: nonceStore{now: cfg.Now, m: map[string]*nonceRecord{}}}, nil
}
type nonceRecord struct {
bind string
expires time.Time
usedBy string
}
type nonceStore struct {
mu sync.Mutex
now func() time.Time
m map[string]*nonceRecord
}
func (s *nonceStore) purge() {
now := s.now()
for k, r := range s.m {
if !r.expires.After(now) {
delete(s.m, k)
}
}
}
func (s *nonceStore) issue(bind string) (string, time.Time, error) {
n, err := randomHex(16)
if err != nil {
return "", time.Time{}, ErrUnavailable
}
s.mu.Lock()
defer s.mu.Unlock()
s.purge()
for len(s.m) >= maxNonces {
var oldest string
for k, r := range s.m {
if oldest == "" || r.expires.Before(s.m[oldest].expires) {
oldest = k
}
}
delete(s.m, oldest)
}
exp := s.now().Add(nonceLife)
s.m[n] = &nonceRecord{bind: bind, expires: exp}
return n, exp, nil
}
// consume uses the nonce once for the operation. The same operation may
// present it again (a replay of the same request).
func (s *nonceStore) consume(nonce, bind, opID string) (replay, ok bool) {
s.mu.Lock()
defer s.mu.Unlock()
s.purge()
r, found := s.m[nonce]
if !found || r.bind != bind {
return false, false
}
if r.usedBy != "" {
return true, r.usedBy == opID
}
r.usedBy = opID
return false, true
}
func nonceBind(a contract.Actor, addonID, digest string, revision uint64) string {
return fmt.Sprintf("%s\x00%s\x00%s\x00%s\x00%d", a.UserID, a.CredentialID, addonID, digest, revision)
}
// Serve runs one call for an actor the verifier or the HTTP layer
// authenticated. Typed refusals are *HostGrantsError.
func (o *HostGrantsOwner) Serve(ctx context.Context, a contract.Actor, call HostGrantsCall) (json.RawMessage, error) {
if !call.valid() {
return nil, ErrForbidden
}
if o.cfg.Admin == nil {
return nil, ErrForbidden
}
if e := ClassifyCheck(o.cfg.Admin(ctx, a)); e != nil {
return nil, e
}
if call.write() {
if !HumanSurfaceOf(ctx) {
o.refused(a, call, "not_human_surface")
return nil, hgErr(403, HostGrantsNotHumanSurface, "This action requires your own browser session")
}
if o.cfg.Locked {
o.refused(a, call, "locked_by_deployment")
return nil, hgErr(403, HostGrantsLocked, "The deployment keeps host access in its policy file; it cannot be changed here")
}
}
var out any
var err error
switch call.Op {
case HostGrantsRead:
out, err = o.read(ctx)
case HostGrantsPreview:
var r HostGrantsPreviewRequest
if !strictBody(call.Body, &r) {
return nil, hgErr(400, HostGrantsInvalidRequest, "The request cannot be carried out as given")
}
out, err = o.preview(ctx, a, r)
case HostGrantsInstall:
var r HostGrantsInstallRequest
if !strictBody(call.Body, &r) || r.OperationID != call.OperationID {
return nil, hgErr(400, HostGrantsInvalidRequest, "The request cannot be carried out as given")
}
out, err = o.install(ctx, a, r)
case HostGrantsEdit:
var r HostGrantsEditRequest
if !strictBody(call.Body, &r) || r.OperationID != call.OperationID {
return nil, hgErr(400, HostGrantsInvalidRequest, "The request cannot be carried out as given")
}
out, err = o.edit(ctx, a, call.AddonID, r)
case HostGrantsRevoke:
var r HostGrantsRevokeRequest
if !strictBody(call.Body, &r) || r.OperationID != call.OperationID {
return nil, hgErr(400, HostGrantsInvalidRequest, "The request cannot be carried out as given")
}
out, err = o.revoke(ctx, a, call.AddonID, r)
case HostGrantsBlanket:
var r HostGrantsBlanketRequest
if !strictBody(call.Body, &r) || r.OperationID != call.OperationID {
return nil, hgErr(400, HostGrantsInvalidRequest, "The request cannot be carried out as given")
}
out, err = o.blanket(ctx, a, r)
case HostGrantsDeny, HostGrantsDenyPreview:
var r HostGrantsDenyRequest
if !strictBody(call.Body, &r) || r.OperationID != call.OperationID {
return nil, hgErr(400, HostGrantsInvalidRequest, "The request cannot be carried out as given")
}
r.DryRun = r.DryRun || call.Op == HostGrantsDenyPreview
if r.DryRun != (call.Op == HostGrantsDenyPreview) {
return nil, hgErr(400, HostGrantsInvalidRequest, "The request cannot be carried out as given")
}
out, err = o.deny(ctx, a, r)
default:
return nil, ErrForbidden
}
if err != nil {
if call.write() {
var he *HostGrantsError
if errors.As(err, &he) {
o.refused(a, call, he.Type)
}
}
return nil, o.mapError(err)
}
raw, e := json.Marshal(out)
if e != nil {
return nil, ErrUnavailable
}
return raw, nil
}
func (o *HostGrantsOwner) refused(a contract.Actor, call HostGrantsCall, reason string) {
_ = o.cfg.Audit.Append(hostgrants.AuditEntry{Event: hostgrants.AuditRefused, AddonID: call.AddonID, OperationID: call.OperationID, By: a.UserID, Credential: credentialKind(a), Reason: reason})
}
func credentialKind(a contract.Actor) string {
switch {
case strings.HasPrefix(a.CredentialID, "session:"):
return "session"
case strings.HasPrefix(a.CredentialID, "key:"):
return "key"
}
return "other"
}
// mapError turns the errors of the store and the manager into typed answers.
func (o *HostGrantsOwner) mapError(err error) error {
var he *HostGrantsError
switch {
case errors.As(err, &he):
return he
case errors.Is(err, hostgrants.ErrConflict):
return hgErr(409, HostGrantsRevisionConflict, "Host access changed since you loaded it; reload and repeat the action")
case errors.Is(err, hostgrants.ErrOperationMismatch):
return hgErr(409, HostGrantsOperationConflict, "The operation ID was used for another request; nothing was applied. Repeat the change under a new operation ID")
case errors.Is(err, hostgrants.ErrInvalid):
return hgErr(400, HostGrantsInvalidRequest, "The request cannot be carried out as given")
case errors.Is(err, ErrApprovalStale):
return hgErr(409, HostGrantsApprovalStale, "The package changed since you reviewed it; review it again")
case errors.Is(err, ErrBlockedByPolicy):
return hgErr(403, HostGrantsBlockedByPolicy, "An operation this add-on needs is blocked by the administrator policy")
case errors.Is(err, ErrApprovalRequired):
return hgErr(403, HostGrantsApprovalRequired, "Approve the host access this add-on needs")
case errors.Is(err, hostgrants.ErrUnavailable), errors.Is(err, hostgrants.ErrUncertain):
return ErrUnavailable
}
return err
}
func (o *HostGrantsOwner) notFound() error {
return hgErr(404, HostGrantsNotFound, "No such add-on")
}
func (o *HostGrantsOwner) invalid() error {
return hgErr(400, HostGrantsInvalidRequest, "The request cannot be carried out as given")
}
func (o *HostGrantsOwner) registered(op string) bool { return slices.Contains(o.cfg.Supported, op) }
func (o *HostGrantsOwner) context(identity string) hostgrants.Context {
return hostgrants.Context{Identity: identity, Completed: o.cfg.Completed, Registered: o.registered}
}
func (o *HostGrantsOwner) describe(v *HostOpView) {
if o.cfg.Describe != nil {
if d, ok := o.cfg.Describe(v.Name); ok {
v.Title, v.Text, v.Risk = d.Title, d.Text, d.Risk
return
}
}
v.Title = v.Name
}
func (o *HostGrantsOwner) blanketMode(doc hostgrants.Snapshot) string {
if o.cfg.BlanketOverride != "" {
return o.cfg.BlanketOverride
}
return doc.Blanket.Mode
}
func unique(lists ...[]string) []string {
seen := map[string]bool{}
var out []string
for _, l := range lists {
for _, s := range l {
if !seen[s] {
seen[s] = true
out = append(out, s)
}
}
}
sort.Strings(out)
return out
}
func status(d hostgrants.Decision, registered bool) string {
switch {
case !registered:
return "unsupported"
case d.Allowed:
return "allowed"
case d.Reason == hostgrants.ReasonDeniedByFile || d.Reason == hostgrants.ReasonDeniedByAdmin:
return "blocked"
}
return "not_allowed"
}
func (o *HostGrantsOwner) addonView(doc hostgrants.Snapshot, in InstalledAccess) HostGrantsAddon {
c := o.context(in.Identity)
declared := unique(in.Required, in.Optional)
req := map[string]bool{}
for _, r := range in.Required {
req[r] = true
}
entry := doc.Grants[in.AddonID]
grant := map[string]hostgrants.OpGrant{}
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,
Requested: HostRequested{Required: nonNil(sorted(in.Required)), Optional: nonNil(sorted(in.Optional))},
Ops: []HostOpView{}, NoLongerRequested: []HostOpView{}}
view := func(op string) HostOpView {
d := o.cfg.Floor.Decide(doc, c, in.AddonID, op)
ov := HostOpView{Name: op, Status: status(d, o.registered(op)), Reason: string(d.Reason), Required: req[op]}
if d.Allowed {
ov.Source = string(d.Source)
}
if g, ok := grant[op]; ok && (d.Allowed || d.Reason == hostgrants.ReasonIdentityMismatch) {
ov.At, ov.By = g.At, g.By
if d.Reason == hostgrants.ReasonIdentityMismatch {
v.IdentityMismatch = true
}
}
o.describe(&ov)
return ov
}
for _, op := range declared {
v.Ops = append(v.Ops, view(op))
}
for _, g := range entry.Ops {
if g.Source == hostgrants.SourceBlanket {
v.BlanketGrantsCount++
}
if !slices.Contains(declared, g.Name) {
v.NoLongerRequested = append(v.NoLongerRequested, view(g.Name))
}
}
sort.Slice(v.NoLongerRequested, func(i, j int) bool { return v.NoLongerRequested[i].Name < v.NoLongerRequested[j].Name })
return v
}
func sorted(in []string) []string {
out := slices.Clone(in)
sort.Strings(out)
return out
}
func nonNil(in []string) []string {
if in == nil {
return []string{}
}
return in
}
func (o *HostGrantsOwner) read(ctx context.Context) (HostGrantsView, error) {
doc, err := o.cfg.Store.Snapshot(ctx)
if err != nil {
return HostGrantsView{}, err
}
installed, err := o.cfg.Manager.HostAccess(ctx)
if err != nil {
return HostGrantsView{}, err
}
v := HostGrantsView{Revision: doc.Revision, Locked: o.cfg.Locked,
Blanket: HostBlanketView{Mode: o.blanketMode(doc), IncludeOptional: doc.Blanket.IncludeOptional, SetBy: "ui", Revision: doc.Blanket.Revision},
Floor: HostGrantsFloor{Source: string(hostgrants.SourceDeployment), Addons: []HostFloorAddon{}},
Deny: HostGrantsDenyView{File: nonNilMap(o.cfg.Floor.Deny), UI: nonNilMap(doc.Deny)},
Addons: []HostGrantsAddon{}}
if o.cfg.BlanketOverride != "" {
v.Blanket.SetBy = "deployment"
}
ids := make([]string, 0, len(o.cfg.Floor.Grants))
for id := range o.cfg.Floor.Grants {
ids = append(ids, id)
}
sort.Strings(ids)
for _, id := range ids {
v.Floor.Addons = append(v.Floor.Addons, HostFloorAddon{ID: id, Prefixes: sorted(o.cfg.Floor.Grants[id])})
}
sort.Slice(installed, func(i, j int) bool { return installed[i].AddonID < installed[j].AddonID })
for _, in := range installed {
v.Addons = append(v.Addons, o.addonView(doc, in))
}
return v, nil
}
func nonNilMap(in map[string][]string) map[string][]string {
out := make(map[string][]string, len(in))
for k, v := range in {
out[k] = sorted(v)
}
return out
}
func (o *HostGrantsOwner) preview(ctx context.Context, a contract.Actor, r HostGrantsPreviewRequest) (HostGrantsPreviewView, error) {
if !contract.ValidAddonID(r.AddonID) || r.SourceID == "" || len(r.SourceID) > 64 || len(r.Version) > 128 {
return HostGrantsPreviewView{}, o.invalid()
}
doc, err := o.cfg.Store.Snapshot(ctx)
if err != nil {
return HostGrantsPreviewView{}, err
}
rel, err := o.cfg.Manager.PreviewHostAccess(ctx, r.SourceID, r.AddonID, r.Version)
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("")
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{}
for _, x := range rel.Required {
req[x] = true
}
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),
Requested: HostRequested{Required: nonNil(sorted(rel.Required)), Optional: nonNil(sorted(rel.Optional))}}
for _, op := range declared {
ov := HostOpView{Name: op, Required: req[op], Hinted: slices.Contains(hint, op)}
switch {
case slices.Contains(plan.Denied, op):
_, why := o.cfg.Floor.Denied(doc, rel.AddonID, op)
ov.Status, ov.Reason = "blocked", string(why)
case slices.Contains(plan.Unsupported, op):
ov.Status = "unsupported"
case slices.Contains(plan.Held, op):
ov.Status = "held"
ov.Source = string(hostgrants.SourceDeployment)
default:
ov.Status = "grantable"
}
o.describe(&ov)
v.Ops = append(v.Ops, ov)
}
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})
v.Blanket = HostBlanketPreview{Applies: b.Applies && len(b.Plan.BlockedRequired) == 0, Revision: b.Revision}
nonce, exp, err := o.nonces.issue(nonceBind(a, rel.AddonID, rel.Digest, doc.Revision))
if err != nil {
return HostGrantsPreviewView{}, err
}
v.Nonce, v.NonceExpiry = nonce, exp.UTC().Format(time.RFC3339)
return v, nil
}
// onlyGrantableMissing is true when every required operation that is not held
// is one the administrator can grant: nothing blocked and nothing unsupported.
func onlyGrantableMissing(p hostgrants.Plan, required []string) bool {
if len(p.BlockedRequired) != 0 {
return false
}
for _, r := range required {
if slices.Contains(p.Unsupported, r) {
return false
}
}
return true
}
func validDigest(s string) bool {
return s != "" && len(s) <= 128 && !strings.ContainsAny(s, " \t\r\n")
}
func (o *HostGrantsOwner) install(ctx context.Context, a contract.Actor, r HostGrantsInstallRequest) (any, error) {
if !contract.ValidAddonID(r.AddonID) || r.SourceID == "" || len(r.SourceID) > 64 || len(r.Version) > 128 ||
(r.Action != "install" && r.Action != "upgrade") || !validDigest(r.Approve.Digest) || r.Approve.Nonce == "" || len(r.Approve.Nonce) > 128 ||
len(r.Approve.Ops) > hostgrants.MaxOpsPerAddon || (r.ExpectedDigest == "") != (r.CandidateDigest == "") ||
(r.Action == "install" && r.ExpectedDigest != "") {
return nil, o.invalid()
}
for _, op := range r.Approve.Ops {
if !hostgrants.ValidDenyEntry(op) || strings.HasSuffix(op, ".") || op == "*" {
return nil, o.invalid()
}
}
doc, err := o.cfg.Store.Snapshot(ctx)
if err != nil {
return nil, err
}
bind := nonceBind(a, r.AddonID, r.Approve.Digest, r.Approve.Revision)
replay, ok := o.nonces.consume(r.Approve.Nonce, bind, r.OperationID)
switch {
case replay && ok:
// The same operation again: the manager answers from its record.
case replay:
return nil, hgErr(409, HostGrantsApprovalStale, "The approval was used for another operation; review the package again")
case r.Approve.Revision != doc.Revision:
// The nonce is used up only when it matched; a stale revision leaves
// the dialog to reload.
return nil, hgErr(409, HostGrantsRevisionConflict, "Host access changed since you loaded it; reload and repeat the action")
case !ok:
return nil, hgErr(409, HostGrantsApprovalStale, "The approval expired or does not match; review the package again")
}
cmd := addons.LifecycleCommand{OperationID: r.OperationID, AddonID: r.AddonID, Action: r.Action, SourceID: r.SourceID, Version: r.Version,
ExpectedDigest: r.ExpectedDigest, CandidateDigest: r.CandidateDigest}
st, err := o.cfg.Manager.SubmitWithApproval(ctx, a, cmd, HostApproval{Digest: r.Approve.Digest, Ops: slices.Clone(r.Approve.Ops), Revision: r.Approve.Revision, Nonce: r.Approve.Nonce})
if err != nil {
return nil, err
}
if o.cfg.AfterChange != nil {
o.cfg.AfterChange([]string{r.AddonID})
}
return st, nil
}
func (o *HostGrantsOwner) find(ctx context.Context, id string) (InstalledAccess, error) {
if !contract.ValidAddonID(id) {
return InstalledAccess{}, o.notFound()
}
list, err := o.cfg.Manager.HostAccess(ctx)
if err != nil {
return InstalledAccess{}, err
}
for _, in := range list {
if in.AddonID == id {
return in, nil
}
}
return InstalledAccess{}, o.notFound()
}
func (o *HostGrantsOwner) now() string { return o.cfg.Now().UTC().Format(time.RFC3339) }
func (o *HostGrantsOwner) edit(ctx context.Context, a contract.Actor, id string, r HostGrantsEditRequest) (any, error) {
if len(r.Ops) > hostgrants.MaxOpsPerAddon {
return nil, o.invalid()
}
in, err := o.find(ctx, id)
if err != nil {
return nil, err
}
doc, err := o.cfg.Store.Snapshot(ctx)
if err != nil {
return nil, err
}
entry := doc.Grants[id]
if entry.Identity != in.Identity {
entry = hostgrants.AddonGrants{}
}
have := map[string]hostgrants.OpGrant{}
for _, g := range entry.Ops {
have[g.Name] = g
}
declared := unique(in.Required, in.Optional)
want := map[string]bool{}
next := hostgrants.AddonGrants{Identity: in.Identity, Generation: in.Generation, OperationID: in.OperationID}
if len(entry.Ops) != 0 {
next.Generation, next.OperationID = entry.Generation, entry.OperationID
}
var added, removed []string
for _, op := range r.Ops {
if want[op] || !hostgrants.ValidDenyEntry(op) || strings.HasSuffix(op, ".") || op == "*" {
return nil, o.invalid()
}
want[op] = true
if g, ok := have[op]; ok {
next.Ops = append(next.Ops, g)
continue
}
if !slices.Contains(declared, op) || !o.registered(op) {
return nil, o.invalid()
}
if denied, _ := o.cfg.Floor.Denied(doc, id, op); denied {
return nil, hgErr(403, HostGrantsBlockedByPolicy, "The administrator policy blocks "+op)
}
next.Ops = append(next.Ops, hostgrants.OpGrant{Name: op, Source: hostgrants.SourceEdit, At: o.now(), By: a.UserID, Version: in.Version})
added = append(added, op)
}
for _, g := range entry.Ops {
if !want[g.Name] {
removed = append(removed, g.Name)
}
}
if r.KeepBlanketAsOwn {
next = hostgrants.KeepAsOwn(next, o.now(), a.UserID)
}
var patch hostgrants.Patch
if len(next.Ops) == 0 {
patch.Addons = map[string]*hostgrants.AddonGrants{id: nil}
} else {
patch.Addons = map[string]*hostgrants.AddonGrants{id: &next}
}
res, err := o.cfg.Store.Update(ctx, patch, r.Revision, r.OperationID)
if err != nil {
return nil, err
}
if !res.Replayed {
o.auditEdit(a, id, in.Version, r.OperationID, r.Revision, res.Snapshot.Revision, added, removed)
if len(added) != 0 && o.cfg.AfterChange != nil {
o.cfg.AfterChange([]string{id})
}
}
view := o.addonView(res.Snapshot, in)
return HostGrantsAddonResult{Revision: res.Snapshot.Revision, Addon: &view}, nil
}
func (o *HostGrantsOwner) auditEdit(a contract.Actor, id, version, opID string, before, after uint64, added, removed []string) {
base := hostgrants.AuditEntry{AddonID: id, Version: version, OperationID: opID, RevisionBefore: before, RevisionAfter: after, By: a.UserID, Credential: credentialKind(a)}
if len(added) != 0 {
e := base
e.Event, e.Ops, e.Source = hostgrants.AuditGrant, added, string(hostgrants.SourceEdit)
_ = o.cfg.Audit.Append(e)
}
if len(removed) != 0 {
e := base
e.Event, e.Ops = hostgrants.AuditRevoke, removed
_ = o.cfg.Audit.Append(e)
}
}
func (o *HostGrantsOwner) revoke(ctx context.Context, a contract.Actor, id string, r HostGrantsRevokeRequest) (any, error) {
if !contract.ValidAddonID(id) {
return nil, o.invalid()
}
doc, err := o.cfg.Store.Snapshot(ctx)
if err != nil {
return nil, err
}
entry, ok := doc.Grants[id]
if !ok {
// Nothing to remove. An unknown add-on is a 404; a known one without
// an entry is already as the caller wants it.
if _, err = o.find(ctx, id); err != nil {
return nil, err
}
if r.Revision != doc.Revision {
return nil, hostgrants.ErrConflict
}
return HostGrantsRevision{Revision: doc.Revision}, nil
}
res, err := o.cfg.Store.Update(ctx, hostgrants.Patch{Addons: map[string]*hostgrants.AddonGrants{id: nil}}, r.Revision, r.OperationID)
if err != nil {
return nil, err
}
if !res.Replayed {
names := make([]string, 0, len(entry.Ops))
for _, g := range entry.Ops {
names = append(names, g.Name)
}
o.auditEdit(a, id, "", r.OperationID, r.Revision, res.Snapshot.Revision, nil, names)
}
return HostGrantsRevision{Revision: res.Snapshot.Revision, Changed: true}, nil
}
func (o *HostGrantsOwner) blanket(ctx context.Context, a contract.Actor, r HostGrantsBlanketRequest) (any, error) {
if o.cfg.BlanketOverride != "" {
return nil, hgErr(403, HostGrantsLocked, "The blanket approval is set by the deployment")
}
switch r.Mode {
case hostgrants.BlanketOff, hostgrants.BlanketOfficial, hostgrants.BlanketAny:
default:
return nil, o.invalid()
}
if (r.Mode == hostgrants.BlanketOff && (r.IncludeOptional || r.ApplyToInstalled)) || (r.Mode != hostgrants.BlanketOff && r.RevokeOnOff) {
return nil, o.invalid()
}
doc, err := o.cfg.Store.Snapshot(ctx)
if err != nil {
return nil, err
}
cur := doc.Blanket
changes := r.Mode != cur.Mode || r.IncludeOptional != cur.IncludeOptional
if r.Mode != hostgrants.BlanketOff && (changes || r.ApplyToInstalled) {
want := BlanketConfirmOfficial
if r.Mode == hostgrants.BlanketAny {
want = BlanketConfirmAny
}
if strings.TrimSpace(r.Confirm) != want {
return nil, hgErr(400, HostGrantsConfirmation, "The typed confirmation does not match")
}
}
rev := cur.Revision
if changes {
rev++
}
next := hostgrants.Blanket{Mode: r.Mode, IncludeOptional: r.IncludeOptional, Revision: rev, ChangedAt: o.now(), ChangedBy: a.UserID}
patch := hostgrants.Patch{Blanket: &next, RevokeBlanket: r.Mode == hostgrants.BlanketOff && r.RevokeOnOff}
out := HostGrantsBlanketResult{Changed: []HostBlanketChange{}, Skipped: []HostBlanketSkip{}, Revoked: []string{}}
if patch.RevokeBlanket {
for id, g := range doc.Grants {
if slices.ContainsFunc(g.Ops, func(x hostgrants.OpGrant) bool { return x.Source == hostgrants.SourceBlanket }) {
out.Revoked = append(out.Revoked, id)
}
}
sort.Strings(out.Revoked)
}
var grantedFor []string
if r.ApplyToInstalled && r.Mode != hostgrants.BlanketOff {
installed, err := o.cfg.Manager.HostAccess(ctx)
if err != nil {
return nil, err
}
sim := doc.Clone()
sim.Blanket = next
patch.Addons = map[string]*hostgrants.AddonGrants{}
for _, in := range installed {
res := o.cfg.Floor.EvaluateBlanket(sim, o.context(in.Identity), "", hostgrants.BlanketInput{AddonID: in.AddonID, Official: in.Official, Required: in.Required, Optional: in.Optional})
switch {
case !res.Applies:
out.Skipped = append(out.Skipped, HostBlanketSkip{ID: in.AddonID, Reason: "out_of_scope"})
case len(res.Plan.BlockedRequired) != 0:
out.Skipped = append(out.Skipped, HostBlanketSkip{ID: in.AddonID, Reason: "blocked_by_policy"})
case len(res.Plan.Grant) != 0:
add := make([]hostgrants.OpGrant, 0, len(res.Plan.Grant))
for _, op := range res.Plan.Grant {
add = append(add, hostgrants.OpGrant{Name: op, Source: hostgrants.SourceBlanket, BlanketRevision: rev, At: o.now(), By: a.UserID, Version: in.Version})
}
merged := hostgrants.MergeGrants(doc.Grants[in.AddonID], in.Identity, in.Generation, in.OperationID, add)
if e := doc.Grants[in.AddonID]; e.Identity == in.Identity && len(e.Ops) != 0 {
merged.Generation, merged.OperationID = e.Generation, e.OperationID
}
patch.Addons[in.AddonID] = &merged
out.Changed = append(out.Changed, HostBlanketChange{ID: in.AddonID, Ops: sorted(res.Plan.Grant)})
grantedFor = append(grantedFor, in.AddonID)
}
}
}
res, err := o.cfg.Store.Update(ctx, patch, r.Revision, r.OperationID)
if err != nil {
return nil, err
}
out.Revision = res.Snapshot.Revision
out.Blanket = HostBlanketView{Mode: res.Snapshot.Blanket.Mode, IncludeOptional: res.Snapshot.Blanket.IncludeOptional, SetBy: "ui", Revision: res.Snapshot.Blanket.Revision}
if !res.Replayed {
tag := fmt.Sprintf("%s@rev%d", next.Mode, next.Revision)
ev := hostgrants.AuditBlanketOn
reason := ""
if next.Mode == hostgrants.BlanketOff {
ev = hostgrants.AuditBlanketOff
if patch.RevokeBlanket {
reason = "revoked_blanket_grants"
} else {
reason = "kept_blanket_grants"
}
}
_ = o.cfg.Audit.Append(hostgrants.AuditEntry{Event: ev, Blanket: tag, OperationID: r.OperationID, RevisionBefore: r.Revision, RevisionAfter: out.Revision, By: a.UserID, Credential: credentialKind(a), Reason: reason})
for _, c := range out.Changed {
_ = o.cfg.Audit.Append(hostgrants.AuditEntry{Event: hostgrants.AuditBlanketGrant, AddonID: c.ID, Ops: c.Ops, Source: string(hostgrants.SourceBlanket), Blanket: tag, OperationID: r.OperationID, RevisionAfter: out.Revision, By: a.UserID})
}
if len(grantedFor) != 0 && o.cfg.AfterChange != nil {
o.cfg.AfterChange(grantedFor)
}
}
return out, nil
}
func (o *HostGrantsOwner) deny(ctx context.Context, a contract.Actor, r HostGrantsDenyRequest) (any, error) {
if r.Deny == nil {
r.Deny = map[string][]string{}
}
if hostgrants.ValidateDeny(r.Deny) != nil {
return nil, o.invalid()
}
doc, err := o.cfg.Store.Snapshot(ctx)
if err != nil {
return nil, err
}
installed, err := o.cfg.Manager.HostAccess(ctx)
if err != nil {
return nil, err
}
sim := doc.Clone()
sim.Deny = r.Deny
affects := []HostDenyAffect{}
for _, in := range installed {
c := o.context(in.Identity)
var loses []string
stop := false
for _, op := range unique(in.Required, in.Optional, opsOf(doc.Grants[in.AddonID])) {
before := o.cfg.Floor.Decide(doc, c, in.AddonID, op)
after := o.cfg.Floor.Decide(sim, c, in.AddonID, op)
if before.Allowed && !after.Allowed {
loses = append(loses, op)
stop = stop || slices.Contains(in.Required, op)
}
}
if len(loses) != 0 {
affects = append(affects, HostDenyAffect{ID: in.AddonID, Loses: loses, WillStop: stop})
}
}
sort.Slice(affects, func(i, j int) bool { return affects[i].ID < affects[j].ID })
if r.DryRun {
if r.Revision != doc.Revision {
return nil, hostgrants.ErrConflict
}
return HostGrantsDenyResult{Revision: doc.Revision, Deny: nonNilMap(r.Deny), Affects: affects, DryRun: true}, nil
}
deny := r.Deny
res, err := o.cfg.Store.Update(ctx, hostgrants.Patch{Deny: &deny}, r.Revision, r.OperationID)
if err != nil {
return nil, err
}
if !res.Replayed {
o.auditDeny(a, r, doc.Deny, res.Snapshot.Revision)
}
return HostGrantsDenyResult{Revision: res.Snapshot.Revision, Deny: nonNilMap(res.Snapshot.Deny), Affects: affects}, nil
}
func opsOf(g hostgrants.AddonGrants) []string {
out := make([]string, 0, len(g.Ops))
for _, x := range g.Ops {
out = append(out, x.Name)
}
return out
}
func (o *HostGrantsOwner) auditDeny(a contract.Actor, r HostGrantsDenyRequest, old map[string][]string, after uint64) {
keys := map[string]bool{}
for k := range old {
keys[k] = true
}
for k := range r.Deny {
keys[k] = true
}
for k := range keys {
var added, removed []string
for _, e := range r.Deny[k] {
if !slices.Contains(old[k], e) {
added = append(added, e)
}
}
for _, e := range old[k] {
if !slices.Contains(r.Deny[k], e) {
removed = append(removed, e)
}
}
base := hostgrants.AuditEntry{AddonID: k, OperationID: r.OperationID, RevisionBefore: r.Revision, RevisionAfter: after, By: a.UserID, Credential: credentialKind(a)}
if len(added) != 0 {
e := base
e.Event, e.Ops = hostgrants.AuditDenyAdd, sorted(added)
_ = o.cfg.Audit.Append(e)
}
if len(removed) != 0 {
e := base
e.Event, e.Ops = hostgrants.AuditDenyRemove, sorted(removed)
_ = o.cfg.Audit.Append(e)
}
}
}
@@ -0,0 +1,135 @@
// SPDX-License-Identifier: MIT
package gateway
import (
"context"
"crypto/ed25519"
"crypto/rand"
"errors"
"net/http"
"time"
"github.com/mudler/LocalAI/core/services/addons/hostgrants"
"github.com/mudler/LocalAI/pkg/addons/contract"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("host access in a split deployment", func() {
ok := func(context.Context, contract.Actor) error { return nil }
actor := contract.Actor{UserID: "admin", CredentialID: "session:abc", Mode: "named-user"}
setup := func(owner *hgFixture) (a, b *Gateway, ownerGW *Gateway) {
var og *Gateway
_, cs := carrierFixture(func(c *Carrier) http.Handler {
og = &Gateway{HostGrantsOwner: owner.svc, Revalidate: ok}
h, e := c.OwnerHandler(og)
Expect(e).NotTo(HaveOccurred())
return h
})
return &Gateway{Forward: cs["front-a"], Revalidate: ok}, &Gateway{Forward: cs["front-b"], Revalidate: ok}, og
}
grant := func(op string, rev uint64) HostGrantsCall {
return call(HostGrantsEdit, op, "sandbox", HostGrantsEditRequest{OperationID: op, Revision: rev, Ops: []string{"egress.check"}})
}
It("forwards a write that carries the human mark and lets both frontends read the one revision", func() {
owner := newHGFixture()
a, b, _ := setup(owner)
r := data[HostGrantsAddonResult](a.HostGrants(human, actor, grant("e1", 0)))
Expect(r.Revision).To(Equal(uint64(1)))
for _, gw := range []*Gateway{a, b} {
v := data[HostGrantsView](gw.HostGrants(context.Background(), actor, call(HostGrantsRead, "", "", nil)))
Expect(v.Revision).To(Equal(uint64(1)))
}
r = data[HostGrantsAddonResult](b.HostGrants(human, actor, grant("e2", 1)))
Expect(r.Revision).To(Equal(uint64(2)))
_, err := a.HostGrants(human, actor, grant("e3", 1))
Expect(typeOf(err)).To(Equal("revision_conflict"), "a stale tab on the other frontend")
snap, err := owner.store.Snapshot(context.Background())
Expect(err).NotTo(HaveOccurred())
Expect(snap.Revision).To(Equal(uint64(2)))
})
It("refuses at the owner a write that the frontend did not mark, whatever else it holds", func() {
owner := newHGFixture()
a, _, _ := setup(owner)
_, err := a.HostGrants(context.Background(), actor, grant("e1", 0))
Expect(typeOf(err)).To(Equal("not_human_surface"))
snap, _ := owner.store.Snapshot(context.Background())
Expect(snap.Revision).To(BeZero())
})
It("lets the typed refusals of the owner through unchanged", func() {
owner := newHGFixture(func(c *HostGrantsConfig) { c.Locked = true })
a, _, _ := setup(owner)
_, err := a.HostGrants(human, actor, grant("e1", 0))
Expect(typeOf(err)).To(Equal("locked_by_deployment"))
var he *HostGrantsError
Expect(errors.As(err, &he)).To(BeTrue())
Expect(he.Status).To(Equal(403))
})
It("never lets a frontend with its own service or flags decide: it forwards", func() {
owner := newHGFixture(func(c *HostGrantsConfig) { c.Locked = true })
a, _, _ := setup(owner)
// A frontend whose own flags would allow everything.
local := newHGFixture(func(c *HostGrantsConfig) {
c.Floor = hostgrants.Floor{Grants: map[string][]string{"sandbox": {"workspace.", "egress."}}}
})
a.HostGrantsOwner = local.svc
_, err := a.HostGrants(human, actor, grant("e1", 0))
Expect(typeOf(err)).To(Equal("locked_by_deployment"), "the owner's decision")
snap, _ := local.store.Snapshot(context.Background())
Expect(snap.Revision).To(BeZero(), "nothing was stored on the frontend")
})
It("reports an owner without host access calls as unavailable with its reason, never as an unknown outcome", func() {
_, cs := carrierFixture(func(c *Carrier) http.Handler {
h, e := c.OwnerHandler(&Gateway{Revalidate: ok})
Expect(e).NotTo(HaveOccurred())
return h
})
a := &Gateway{Forward: cs["front-a"], Revalidate: ok}
_, err := a.HostGrants(human, actor, grant("e1", 0))
Expect(err).To(MatchError(ErrUnavailable))
Expect(UnavailableReason(err)).To(Equal(ReasonHostGrantsUnsupported))
})
It("signs the human mark so it cannot be added after the fact", func() {
pub, priv, e := ed25519.GenerateKey(rand.Reader)
Expect(e).NotTo(HaveOccurred())
v := &ForwardVerifier{OwnerID: "owner", Keys: map[string]ed25519.PublicKey{"front": pub}, Revalidate: ok}
peer := PeerIdentity{ID: "front", Verified: true}
msg := func(nonce string, mark bool) ForwardedRequest {
return ForwardedRequest{PeerID: "front", Audience: "owner", Actor: actor, OperationID: "op", Nonce: nonce, ExpiresAt: time.Now().Add(time.Minute), Body: []byte(`{"operationId":"op"}`), HumanSurface: mark}
}
m := msg("n1", true)
m.Sign(priv)
_, e = v.Verify(context.Background(), peer, m)
Expect(e).NotTo(HaveOccurred())
added := msg("n2", false)
added.Sign(priv)
added.HumanSurface = true
_, e = v.Verify(context.Background(), peer, added)
Expect(e).To(MatchError(ErrForbidden), "a mark cannot be added to a signed request")
plain := msg("n3", false)
plain.Sign(priv)
Expect(string(plain.signingBytes())).NotTo(ContainSubstring("humanSurface"), "other requests sign as before")
})
It("does not carry the mark on a call that is not a host access call", func() {
var seen []bool
_, cs := carrierFixture(func(c *Carrier) http.Handler {
h, e := c.OwnerHandler(&Gateway{Revalidate: func(ctx context.Context, _ contract.Actor) error {
seen = append(seen, HumanSurfaceOf(ctx))
return nil
}})
Expect(e).NotTo(HaveOccurred())
return h
})
_, _ = cs["front-a"].Dispatch(human, actor, contract.Request{OperationID: "op1", Action: "operation-status"})
Expect(seen).NotTo(BeEmpty())
Expect(seen).NotTo(ContainElement(true))
})
})
@@ -0,0 +1,533 @@
// SPDX-License-Identifier: MIT
package gateway
import (
"context"
"encoding/json"
"errors"
"os"
"path/filepath"
"sync"
"time"
"github.com/mudler/LocalAI/core/services/addons"
"github.com/mudler/LocalAI/core/services/addons/hostgrants"
"github.com/mudler/LocalAI/pkg/addons/contract"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
type submitRecord struct {
Cmd addons.LifecycleCommand
Approval HostApproval
}
// fakeAccess stands in for the manager: the approval itself runs inside it.
type fakeAccess struct {
mu sync.Mutex
installed []InstalledAccess
release ReleaseAccess
submitted []submitRecord
submitErr error
}
func (f *fakeAccess) HostAccess(context.Context) ([]InstalledAccess, error) {
f.mu.Lock()
defer f.mu.Unlock()
return append([]InstalledAccess(nil), f.installed...), nil
}
func (f *fakeAccess) PreviewHostAccess(context.Context, string, string, string) (ReleaseAccess, error) {
f.mu.Lock()
defer f.mu.Unlock()
return f.release, nil
}
func (f *fakeAccess) SubmitWithApproval(_ context.Context, _ contract.Actor, cmd addons.LifecycleCommand, ap HostApproval) (addons.OperationStatus, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.submitted = append(f.submitted, submitRecord{cmd, ap})
if f.submitErr != nil {
return addons.OperationStatus{}, f.submitErr
}
return addons.OperationStatus{OperationID: cmd.OperationID, AddonID: cmd.AddonID, Phase: "completed"}, nil
}
func call(op, opID, addon string, body any) HostGrantsCall {
raw, _ := json.Marshal(body)
return HostGrantsCall{Op: op, OperationID: opID, AddonID: addon, Body: raw}
}
type hgFixture struct {
svc *HostGrantsOwner
store *hostgrants.Store
mgr *fakeAccess
cfg HostGrantsConfig
}
func newHGFixture(mutate ...func(*HostGrantsConfig)) *hgFixture {
store, err := hostgrants.Open(filepath.Join(GinkgoT().TempDir(), "hostgrants.json"))
Expect(err).NotTo(HaveOccurred())
DeferCleanup(store.Close)
mgr := &fakeAccess{
installed: []InstalledAccess{
{AddonID: "sandbox", Title: "Sandbox", Version: "1.0.0", State: "running", Identity: "id-sbx", Generation: 1, OperationID: "op-install",
Required: []string{"workspace.materialize"}, Optional: []string{"egress.check", "connector.inbound"}},
{AddonID: "web", Title: "Web", Version: "2.0.0", State: "running", Identity: "id-web", Generation: 1, OperationID: "op-web", Required: []string{"egress.check"}},
},
release: ReleaseAccess{AddonID: "newone", Title: "New one", Version: "1.0.0", Digest: "sha256:aa", Official: true,
Required: []string{"workspace.materialize"}, Optional: []string{"egress.check"}},
}
cfg := HostGrantsConfig{
Store: store, Manager: mgr,
Floor: hostgrants.Floor{Grants: map[string][]string{"web": {"egress."}}, Deny: map[string][]string{"*": {"connector."}}},
Admin: func(context.Context, contract.Actor) error { return nil },
Describe: func(op string) (HostOpDescription, bool) {
return HostOpDescription{Title: "T " + op, Text: "text", Risk: "read"}, true
},
}
for _, m := range mutate {
m(&cfg)
}
svc, err := NewHostGrantsOwner(cfg)
Expect(err).NotTo(HaveOccurred())
return &hgFixture{svc: svc, store: store, mgr: mgr, cfg: cfg}
}
var human = WithHumanSurface(context.Background())
var admin = contract.Actor{UserID: "admin", CredentialID: "session:abc", Mode: "named-user"}
func data[T any](raw json.RawMessage, err error) T {
GinkgoHelper()
Expect(err).NotTo(HaveOccurred())
var v T
Expect(json.Unmarshal(raw, &v)).To(Succeed())
return v
}
func typeOf(err error) string {
var he *HostGrantsError
if errors.As(err, &he) {
return he.Type
}
return "other:" + err.Error()
}
var _ = Describe("The host access owner service", func() {
read := func(f *hgFixture) HostGrantsView {
return data[HostGrantsView](f.svc.Serve(context.Background(), admin, call(HostGrantsRead, "", "", nil)))
}
opOf := func(a HostGrantsAddon, name string) HostOpView {
for _, o := range a.Ops {
if o.Name == name {
return o
}
}
Fail("no op " + name)
return HostOpView{}
}
addon := func(v HostGrantsView, id string) HostGrantsAddon {
for _, a := range v.Addons {
if a.ID == id {
return a
}
}
Fail("no add-on " + id)
return HostGrantsAddon{}
}
It("reads the floor, the deny rules and the state of each add-on, with a file deny shown as blocked", func() {
f := newHGFixture()
v := read(f)
Expect(v.Floor.Addons).To(Equal([]HostFloorAddon{{ID: "web", Prefixes: []string{"egress."}}}))
Expect(v.Deny.File).To(HaveKeyWithValue("*", []string{"connector."}))
web := addon(v, "web")
Expect(opOf(web, "egress.check").Status).To(Equal("allowed"))
Expect(opOf(web, "egress.check").Source).To(Equal("deployment"))
sbx := addon(v, "sandbox")
Expect(opOf(sbx, "workspace.materialize").Status).To(Equal("not_allowed"))
Expect(opOf(sbx, "workspace.materialize").Required).To(BeTrue())
Expect(opOf(sbx, "connector.inbound").Status).To(Equal("blocked"))
Expect(opOf(sbx, "connector.inbound").Reason).To(Equal("denied_by_policy_file"))
Expect(opOf(sbx, "workspace.materialize").Title).To(Equal("T workspace.materialize"))
Expect(v.Blanket.Mode).To(Equal("off"))
})
It("refuses a non-administrator before it reads anything", func() {
f := newHGFixture(func(c *HostGrantsConfig) {
c.Admin = func(context.Context, contract.Actor) error { return ErrForbidden }
})
_, err := f.svc.Serve(context.Background(), admin, call(HostGrantsRead, "", "", nil))
Expect(err).To(MatchError(ErrForbidden))
})
Describe("preview", func() {
preview := func(f *hgFixture) HostGrantsPreviewView {
return data[HostGrantsPreviewView](f.svc.Serve(context.Background(), admin, call(HostGrantsPreview, "", "", HostGrantsPreviewRequest{SourceID: "official", AddonID: "newone", Version: "1.0.0"})))
}
It("lists the verified request with its status and a nonce", func() {
f := newHGFixture()
v := preview(f)
Expect(v.Digest).To(Equal("sha256:aa"))
Expect(v.CanInstall).To(BeTrue())
Expect(v.Nonce).NotTo(BeEmpty())
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.Blanket.Applies).To(BeFalse())
})
It("shows a deny winning over everything, and cannot install a blocked required operation", func() {
f := newHGFixture(func(c *HostGrantsConfig) {
c.Floor.Grants = map[string][]string{"newone": {"workspace."}}
})
_, err := f.store.Update(context.Background(), hostgrants.Patch{Deny: &map[string][]string{"newone": {"workspace.materialize"}}}, 0, "deny-1")
Expect(err).NotTo(HaveOccurred())
v := preview(f)
Expect(v.CanInstall).To(BeFalse())
Expect(v.BlockedReq).To(Equal([]string{"workspace.materialize"}))
var got HostOpView
for _, o := range v.Ops {
if o.Name == "workspace.materialize" {
got = o
}
}
Expect(got.Status).To(Equal("blocked"), "the file grants it, the UI deny blocks it")
Expect(got.Reason).To(Equal("denied_by_admin"))
})
It("marks what a deployment prefix holds, and an operation granted before an uninstall as a hint only", func() {
f := newHGFixture(func(c *HostGrantsConfig) {
c.Floor.Grants = map[string][]string{"newone": {"egress."}}
})
_, err := f.store.Update(context.Background(), hostgrants.Patch{Hints: map[string][]string{"newone": {"workspace.materialize"}}}, 0, "hint-1")
Expect(err).NotTo(HaveOccurred())
v := preview(f)
for _, o := range v.Ops {
switch o.Name {
case "egress.check":
Expect(o.Status).To(Equal("held"))
case "workspace.materialize":
Expect(o.Status).To(Equal("grantable"))
Expect(o.Hinted).To(BeTrue())
}
}
})
It("says when the blanket switch would grant on install", func() {
f := newHGFixture()
_, err := f.svc.Serve(human, admin, call(HostGrantsBlanket, "b1", "", HostGrantsBlanketRequest{OperationID: "b1", Mode: "official", Confirm: BlanketConfirmOfficial}))
Expect(err).NotTo(HaveOccurred())
Expect(preview(f).Blanket.Applies).To(BeTrue())
f.mgr.release.Official = false
Expect(preview(f).Blanket.Applies).To(BeFalse(), "an unofficial package is out of scope of official")
})
})
Describe("install with approval", func() {
req := func(v HostGrantsPreviewView, op string) HostGrantsInstallRequest {
return HostGrantsInstallRequest{OperationID: op, Action: "install", SourceID: "official", AddonID: "newone", Version: "1.0.0",
Approve: HostGrantsApprove{Digest: v.Digest, Ops: []string{"workspace.materialize"}, Revision: v.Revision, Nonce: v.Nonce}}
}
prev := func(f *hgFixture) HostGrantsPreviewView {
return data[HostGrantsPreviewView](f.svc.Serve(context.Background(), admin, call(HostGrantsPreview, "", "", HostGrantsPreviewRequest{SourceID: "official", AddonID: "newone", Version: "1.0.0"})))
}
It("passes the approval to the manager and answers the operation", func() {
f := newHGFixture()
v := prev(f)
st := data[addons.OperationStatus](f.svc.Serve(human, admin, call(HostGrantsInstall, "i1", "", req(v, "i1"))))
Expect(st.OperationID).To(Equal("i1"))
Expect(f.mgr.submitted).To(HaveLen(1))
Expect(f.mgr.submitted[0].Approval).To(Equal(HostApproval{Digest: "sha256:aa", Ops: []string{"workspace.materialize"}, Revision: v.Revision, Nonce: v.Nonce}))
Expect(f.mgr.submitted[0].Cmd.Action).To(Equal("install"))
})
It("refuses without the human surface and never reaches the manager", func() {
f := newHGFixture()
v := prev(f)
_, err := f.svc.Serve(context.Background(), admin, call(HostGrantsInstall, "i1", "", req(v, "i1")))
Expect(typeOf(err)).To(Equal("not_human_surface"))
Expect(f.mgr.submitted).To(BeEmpty())
})
It("refuses a stale revision as 409 revision_conflict", func() {
f := newHGFixture()
v := prev(f)
_, err := f.store.Update(context.Background(), hostgrants.Patch{Deny: &map[string][]string{}}, 0, "other")
Expect(err).NotTo(HaveOccurred())
_, err = f.svc.Serve(human, admin, call(HostGrantsInstall, "i1", "", req(v, "i1")))
Expect(typeOf(err)).To(Equal("revision_conflict"))
var he *HostGrantsError
Expect(errors.As(err, &he)).To(BeTrue())
Expect(he.Status).To(Equal(409))
Expect(f.mgr.submitted).To(BeEmpty())
})
It("uses the nonce once, lets the same operation replay, and binds it to the credential and the digest", func() {
f := newHGFixture()
v := prev(f)
r := req(v, "i1")
_, err := f.svc.Serve(human, admin, call(HostGrantsInstall, "i1", "", r))
Expect(err).NotTo(HaveOccurred())
_, err = f.svc.Serve(human, admin, call(HostGrantsInstall, "i1", "", r))
Expect(err).NotTo(HaveOccurred(), "a replay of the same operation")
other := r
other.OperationID = "i2"
_, err = f.svc.Serve(human, admin, call(HostGrantsInstall, "i2", "", other))
Expect(typeOf(err)).To(Equal("approval_stale"), "another operation cannot reuse it")
v2 := prev(f)
key := admin
key.CredentialID = "key:k1"
_, err = f.svc.Serve(human, key, call(HostGrantsInstall, "i3", "", req(v2, "i3")))
Expect(typeOf(err)).To(Equal("approval_stale"), "a nonce from a session is not good for another credential")
wrongDigest := req(v2, "i4")
wrongDigest.Approve.Digest = "sha256:bb"
_, err = f.svc.Serve(human, admin, call(HostGrantsInstall, "i4", "", wrongDigest))
Expect(typeOf(err)).To(Equal("approval_stale"))
Expect(f.mgr.submitted).To(HaveLen(2), "install and its replay only")
})
It("expires a nonce", func() {
now := time.Now()
f := newHGFixture(func(c *HostGrantsConfig) { c.Now = func() time.Time { return now } })
v := prev(f)
now = now.Add(6 * time.Minute)
_, err := f.svc.Serve(human, admin, call(HostGrantsInstall, "i1", "", req(v, "i1")))
Expect(typeOf(err)).To(Equal("approval_stale"))
})
It("types the decisions of the manager", func() {
for err, want := range map[error]string{ErrApprovalStale: "approval_stale", ErrBlockedByPolicy: "blocked_by_policy", ErrApprovalRequired: "approval_required"} {
f := newHGFixture()
f.mgr.submitErr = err
v := prev(f)
_, got := f.svc.Serve(human, admin, call(HostGrantsInstall, "i1", "", req(v, "i1")))
Expect(typeOf(got)).To(Equal(want))
}
})
It("rejects an upgrade with one pin, an unknown field and a body that names another operation", func() {
f := newHGFixture()
v := prev(f)
r := req(v, "i1")
r.Action, r.ExpectedDigest = "upgrade", "sha256:old"
_, err := f.svc.Serve(human, admin, call(HostGrantsInstall, "i1", "", r))
Expect(typeOf(err)).To(Equal("invalid_request"))
_, err = f.svc.Serve(human, admin, HostGrantsCall{Op: HostGrantsInstall, OperationID: "i1", Body: json.RawMessage(`{"operationId":"i1","extra":1}`)})
Expect(typeOf(err)).To(Equal("invalid_request"))
_, err = f.svc.Serve(human, admin, call(HostGrantsInstall, "i9", "", req(v, "i1")))
Expect(typeOf(err)).To(Equal("invalid_request"))
})
})
Describe("edit, revoke", func() {
edit := func(f *hgFixture, op string, rev uint64, ops ...string) (json.RawMessage, error) {
return f.svc.Serve(human, admin, call(HostGrantsEdit, op, "sandbox", HostGrantsEditRequest{OperationID: op, Revision: rev, Ops: ops}))
}
It("grants a declared operation, shows it, and revokes it again", func() {
f := newHGFixture()
r := data[HostGrantsAddonResult](edit(f, "e1", 0, "workspace.materialize", "egress.check"))
Expect(r.Revision).To(Equal(uint64(1)))
Expect(opOf(*r.Addon, "workspace.materialize").Status).To(Equal("allowed"))
Expect(opOf(*r.Addon, "workspace.materialize").Source).To(Equal("edit"))
Expect(opOf(*r.Addon, "workspace.materialize").By).To(Equal("admin"))
r = data[HostGrantsAddonResult](edit(f, "e2", 1, "egress.check"))
Expect(opOf(*r.Addon, "workspace.materialize").Status).To(Equal("not_allowed"))
Expect(opOf(*r.Addon, "egress.check").Status).To(Equal("allowed"))
rv := data[HostGrantsRevision](f.svc.Serve(human, admin, call(HostGrantsRevoke, "r1", "sandbox", HostGrantsRevokeRequest{OperationID: "r1", Revision: 2})))
Expect(rv).To(Equal(HostGrantsRevision{Revision: 3, Changed: true}))
Expect(opOf(addon(read(f), "sandbox"), "egress.check").Status).To(Equal("not_allowed"))
})
It("refuses a stale revision with 409 and an operation replayed with another body with 409", func() {
f := newHGFixture()
_, err := edit(f, "e1", 0, "workspace.materialize")
Expect(err).NotTo(HaveOccurred())
_, err = edit(f, "e2", 0, "workspace.materialize")
Expect(typeOf(err)).To(Equal("revision_conflict"))
_, err = edit(f, "e1", 1, "egress.check")
Expect(typeOf(err)).To(Equal("operation_conflict"))
r := data[HostGrantsAddonResult](edit(f, "e1", 0, "workspace.materialize"))
Expect(r.Revision).To(Equal(uint64(1)), "the same operation again is the same change")
})
It("refuses an operation the manifest does not declare or the host does not register, and one that is denied", func() {
f := newHGFixture()
_, err := edit(f, "e1", 0, "finetune.start")
Expect(typeOf(err)).To(Equal("invalid_request"))
_, err = edit(f, "e2", 0, "connector.inbound")
Expect(typeOf(err)).To(Equal("blocked_by_policy"))
_, err = f.svc.Serve(human, admin, call(HostGrantsEdit, "e3", "ghost", HostGrantsEditRequest{OperationID: "e3", Ops: []string{"egress.check"}}))
Expect(typeOf(err)).To(Equal("not_found"))
})
It("does not grant an entry from a package with another identity", func() {
f := newHGFixture()
_, err := edit(f, "e1", 0, "workspace.materialize")
Expect(err).NotTo(HaveOccurred())
f.mgr.installed[0].Identity = "someone-else"
v := read(f)
sbx := addon(v, "sandbox")
Expect(opOf(sbx, "workspace.materialize").Status).To(Equal("not_allowed"))
Expect(sbx.IdentityMismatch).To(BeTrue())
})
It("lists a grant the manifest no longer asks for, and relabels blanket grants on request", func() {
f := newHGFixture()
_, err := edit(f, "e1", 0, "workspace.materialize", "egress.check")
Expect(err).NotTo(HaveOccurred())
f.mgr.installed[0].Optional = []string{"connector.inbound"}
Expect(addon(read(f), "sandbox").NoLongerRequested).To(HaveLen(1))
Expect(addon(read(f), "sandbox").NoLongerRequested[0].Name).To(Equal("egress.check"))
})
It("revoke of an unknown add-on is 404 and of one without grants changes nothing", func() {
f := newHGFixture()
_, err := f.svc.Serve(human, admin, call(HostGrantsRevoke, "r1", "ghost", HostGrantsRevokeRequest{OperationID: "r1"}))
Expect(typeOf(err)).To(Equal("not_found"))
rv := data[HostGrantsRevision](f.svc.Serve(human, admin, call(HostGrantsRevoke, "r2", "sandbox", HostGrantsRevokeRequest{OperationID: "r2"})))
Expect(rv).To(Equal(HostGrantsRevision{}))
})
It("refuses every write without the human surface, and when the deployment locks the store", func() {
f := newHGFixture()
for _, c := range []HostGrantsCall{
call(HostGrantsEdit, "x1", "sandbox", HostGrantsEditRequest{OperationID: "x1"}),
call(HostGrantsRevoke, "x2", "sandbox", HostGrantsRevokeRequest{OperationID: "x2"}),
call(HostGrantsBlanket, "x3", "", HostGrantsBlanketRequest{OperationID: "x3", Mode: "off"}),
call(HostGrantsDeny, "x4", "", HostGrantsDenyRequest{OperationID: "x4"}),
call(HostGrantsInstall, "x5", "", HostGrantsInstallRequest{OperationID: "x5"}),
} {
_, err := f.svc.Serve(context.Background(), admin, c)
Expect(typeOf(err)).To(Equal("not_human_surface"), c.Op)
}
locked := newHGFixture(func(c *HostGrantsConfig) { c.Locked = true })
_, err := locked.svc.Serve(human, admin, call(HostGrantsEdit, "x1", "sandbox", HostGrantsEditRequest{OperationID: "x1", Ops: []string{"egress.check"}}))
Expect(typeOf(err)).To(Equal("locked_by_deployment"))
Expect(read(locked).Locked).To(BeTrue(), "reads still work")
})
})
Describe("blanket", func() {
b := func(f *hgFixture, op string, rev uint64, r HostGrantsBlanketRequest) (json.RawMessage, error) {
r.OperationID, r.Revision = op, rev
return f.svc.Serve(human, admin, call(HostGrantsBlanket, op, "", r))
}
It("needs the typed text, stated for the scope", func() {
f := newHGFixture()
_, err := b(f, "b1", 0, HostGrantsBlanketRequest{Mode: "any", Confirm: BlanketConfirmOfficial})
Expect(typeOf(err)).To(Equal("confirmation_mismatch"))
_, err = b(f, "b2", 0, HostGrantsBlanketRequest{Mode: "official"})
Expect(typeOf(err)).To(Equal("confirmation_mismatch"))
r := data[HostGrantsBlanketResult](b(f, "b3", 0, HostGrantsBlanketRequest{Mode: "any", Confirm: BlanketConfirmAny}))
Expect(r.Blanket.Mode).To(Equal("any"))
Expect(r.Blanket.Revision).To(Equal(uint64(1)))
Expect(BlanketConfirmAny).To(ContainSubstring("any library"))
})
It("grants the installed add-ons what they declare, never a denied one, and skips what is blocked", func() {
f := newHGFixture()
r := data[HostGrantsBlanketResult](b(f, "b1", 0, HostGrantsBlanketRequest{Mode: "any", IncludeOptional: true, ApplyToInstalled: true, Confirm: BlanketConfirmAny}))
Expect(r.Changed).To(ConsistOf(HostBlanketChange{ID: "sandbox", Ops: []string{"egress.check", "workspace.materialize"}}))
sbx := addon(read(f), "sandbox")
Expect(opOf(sbx, "workspace.materialize").Source).To(Equal("blanket"))
Expect(opOf(sbx, "connector.inbound").Status).To(Equal("blocked"), "a deny beats the blanket")
Expect(sbx.BlanketGrantsCount).To(Equal(2))
})
It("covers only official add-ons in official mode", func() {
f := newHGFixture()
f.mgr.installed[0].Official = true
r := data[HostGrantsBlanketResult](b(f, "b1", 0, HostGrantsBlanketRequest{Mode: "official", ApplyToInstalled: true, Confirm: BlanketConfirmOfficial}))
Expect(r.Changed).To(ConsistOf(HostBlanketChange{ID: "sandbox", Ops: []string{"workspace.materialize"}}))
Expect(r.Skipped).To(ContainElement(HostBlanketSkip{ID: "web", Reason: "out_of_scope"}))
})
It("keeps the grants it made when turned off, or revokes them all when asked", func() {
f := newHGFixture()
_, err := b(f, "b1", 0, HostGrantsBlanketRequest{Mode: "any", ApplyToInstalled: true, Confirm: BlanketConfirmAny})
Expect(err).NotTo(HaveOccurred())
r := data[HostGrantsBlanketResult](b(f, "b2", 1, HostGrantsBlanketRequest{Mode: "off"}))
Expect(r.Revoked).To(BeEmpty())
Expect(opOf(addon(read(f), "sandbox"), "workspace.materialize").Status).To(Equal("allowed"))
r = data[HostGrantsBlanketResult](b(f, "b3", 2, HostGrantsBlanketRequest{Mode: "off", RevokeOnOff: true}))
Expect(r.Revoked).To(Equal([]string{"sandbox"}))
Expect(opOf(addon(read(f), "sandbox"), "workspace.materialize").Status).To(Equal("not_allowed"))
})
It("is refused when the deployment fixes the mode, and reads as set by the deployment", func() {
f := newHGFixture(func(c *HostGrantsConfig) { c.BlanketOverride = "official" })
_, err := b(f, "b1", 0, HostGrantsBlanketRequest{Mode: "off"})
Expect(typeOf(err)).To(Equal("locked_by_deployment"))
v := read(f)
Expect(v.Blanket.Mode).To(Equal("official"))
Expect(v.Blanket.SetBy).To(Equal("deployment"))
})
It("rejects options that do not fit the mode", func() {
f := newHGFixture()
_, err := b(f, "b1", 0, HostGrantsBlanketRequest{Mode: "off", IncludeOptional: true})
Expect(typeOf(err)).To(Equal("invalid_request"))
_, err = b(f, "b2", 0, HostGrantsBlanketRequest{Mode: "sometimes"})
Expect(typeOf(err)).To(Equal("invalid_request"))
})
})
Describe("deny", func() {
d := func(f *hgFixture, op string, rev uint64, rules map[string][]string, dry bool) (json.RawMessage, error) {
kind := HostGrantsDeny
id := op
if dry {
kind, id = HostGrantsDenyPreview, ""
}
return f.svc.Serve(human, admin, call(kind, id, "", HostGrantsDenyRequest{OperationID: id, Revision: rev, Deny: rules, DryRun: dry}))
}
It("shows what a rule takes before it applies and what makes an add-on stop", func() {
f := newHGFixture()
_, err := f.svc.Serve(human, admin, call(HostGrantsEdit, "e1", "sandbox", HostGrantsEditRequest{OperationID: "e1", Ops: []string{"workspace.materialize", "egress.check"}}))
Expect(err).NotTo(HaveOccurred())
dry := data[HostGrantsDenyResult](d(f, "", 1, map[string][]string{"sandbox": {"workspace."}, "*": {"egress."}}, true))
Expect(dry.DryRun).To(BeTrue())
Expect(dry.Affects).To(Equal([]HostDenyAffect{
{ID: "sandbox", Loses: []string{"egress.check", "workspace.materialize"}, WillStop: true},
{ID: "web", Loses: []string{"egress.check"}, WillStop: true},
}))
Expect(read(f).Deny.UI).To(BeEmpty(), "a dry run changes nothing")
real := data[HostGrantsDenyResult](d(f, "d1", 1, map[string][]string{"sandbox": {"workspace."}}, false))
Expect(real.Revision).To(Equal(uint64(2)))
sbx := addon(read(f), "sandbox")
Expect(opOf(sbx, "workspace.materialize").Status).To(Equal("blocked"))
Expect(opOf(sbx, "workspace.materialize").Reason).To(Equal("denied_by_admin"))
})
It("refuses an invalid rule, a stale revision, and the dry run needs no human surface but a write does", func() {
f := newHGFixture()
_, err := d(f, "d1", 0, map[string][]string{"Bad ID": {"egress."}}, false)
Expect(typeOf(err)).To(Equal("invalid_request"))
_, err = d(f, "d2", 0, map[string][]string{"*": {"x", "*"}}, false)
Expect(typeOf(err)).To(Equal("invalid_request"))
_, err = d(f, "d3", 5, map[string][]string{}, false)
Expect(typeOf(err)).To(Equal("revision_conflict"))
_, err = f.svc.Serve(context.Background(), admin, call(HostGrantsDenyPreview, "", "", HostGrantsDenyRequest{Deny: map[string][]string{}, DryRun: true}))
Expect(err).NotTo(HaveOccurred())
_, err = f.svc.Serve(human, admin, call(HostGrantsDeny, "d4", "", HostGrantsDenyRequest{OperationID: "d4", DryRun: true}))
Expect(typeOf(err)).To(Equal("invalid_request"), "a dry run is its own verb")
})
})
It("writes an audit line for each change, with the credential kind and never a request body", func() {
path := filepath.Join(GinkgoT().TempDir(), "audit.jsonl")
a, err := hostgrants.OpenAudit(path)
Expect(err).NotTo(HaveOccurred())
DeferCleanup(a.Close)
f := newHGFixture(func(c *HostGrantsConfig) { c.Audit = a })
_, err = f.svc.Serve(human, admin, call(HostGrantsEdit, "e1", "sandbox", HostGrantsEditRequest{OperationID: "e1", Ops: []string{"egress.check"}}))
Expect(err).NotTo(HaveOccurred())
_, err = f.svc.Serve(context.Background(), admin, call(HostGrantsEdit, "e2", "sandbox", HostGrantsEditRequest{OperationID: "e2"}))
Expect(err).To(HaveOccurred())
raw := readFile(path)
Expect(raw).To(ContainSubstring(`"event":"grant"`))
Expect(raw).To(ContainSubstring(`"credential":"session"`))
Expect(raw).To(ContainSubstring(`"event":"refused"`))
Expect(raw).To(ContainSubstring(`"reason":"not_human_surface"`))
})
})
func readFile(p string) string {
GinkgoHelper()
b, err := os.ReadFile(p)
Expect(err).NotTo(HaveOccurred())
return string(b)
}
var _ = Describe("host access actions on the typed dispatch", func() {
It("is not reachable: a dispatch of a host access action is forbidden on the owner", func() {
g := &Gateway{Revalidate: func(context.Context, contract.Actor) error { return nil }}
for _, action := range []string{"hostgrants-edit", "hostgrants-install", "hostgrants-blanket", "hostgrants-read"} {
_, err := g.Dispatch(human, admin, contract.Request{OperationID: "op", Action: action, Arguments: json.RawMessage(`{}`)})
Expect(err).To(MatchError(ErrForbidden), action)
}
})
})
+3
View File
@@ -197,6 +197,9 @@ func (g *Gateway) capabilities() Capabilities {
if g.ModelsProbe != nil {
c.Calls = append(c.Calls, "models-probe")
}
if g.HostGrantsOwner != nil {
c.Calls = append(c.Calls, CallHostGrants)
}
return c
}
@@ -0,0 +1,20 @@
// SPDX-License-Identifier: MIT
package hostgrants_test
import (
"testing"
"github.com/mudler/LocalAI/core/config"
"github.com/mudler/LocalAI/core/services/addons/hostgrants"
)
// An external test package: core/config imports the gateway, which imports
// hostgrants, so an internal test could not import core/config.
func TestDenyGrammarMatchesThePolicyFile(t *testing.T) {
for _, entry := range []string{"*", "egress.", "workspace.admin", "connector.file.", "connector.file.put", "X.", "workspace", "a..", ".a", "a.b.", "a.B", "", "a.b.c.", "a-b.c"} {
_, err := config.ParseAddonHostDeny("a=" + entry)
if (err == nil) != hostgrants.ValidDenyEntry(entry) {
t.Errorf("entry %q: config accepts=%v, hostgrants accepts=%v", entry, err == nil, hostgrants.ValidDenyEntry(entry))
}
}
}
@@ -5,8 +5,6 @@ import (
"fmt"
"reflect"
"testing"
"github.com/mudler/LocalAI/core/config"
)
const (
@@ -309,13 +307,3 @@ func TestMergeGrants(t *testing.T) {
t.Fatalf("an entry of another identity is not carried over: %+v", fresh)
}
}
// The deny grammar of the document must be the grammar of the policy file.
func TestDenyGrammarMatchesThePolicyFile(t *testing.T) {
for _, entry := range []string{"*", "egress.", "workspace.admin", "connector.file.", "connector.file.put", "X.", "workspace", "a..", ".a", "a.b.", "a.B", "", "a.b.c.", "a-b.c"} {
_, err := config.ParseAddonHostDeny("a=" + entry)
if (err == nil) != ValidDenyEntry(entry) {
t.Errorf("entry %q: config accepts=%v, hostgrants accepts=%v", entry, err == nil, ValidDenyEntry(entry))
}
}
}