From 18e667cafb760aeba18133871333b7087ce6fccd Mon Sep 17 00:00:00 2001 From: Ettore Di Giacinto Date: Tue, 3 Nov 2026 21:43:46 +0000 Subject: [PATCH] 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 --- core/services/addons/gateway/carrier.go | 25 +- core/services/addons/gateway/forwarding.go | 9 +- core/services/addons/gateway/gateway.go | 4 + core/services/addons/gateway/hostgrants.go | 530 +++++++++++ .../addons/gateway/hostgrants_owner.go | 870 ++++++++++++++++++ .../addons/gateway/hostgrants_split_test.go | 135 +++ .../addons/gateway/hostgrants_test.go | 533 +++++++++++ core/services/addons/gateway/library.go | 3 + .../addons/hostgrants/grammar_test.go | 20 + .../services/addons/hostgrants/policy_test.go | 12 - 10 files changed, 2125 insertions(+), 16 deletions(-) create mode 100644 core/services/addons/gateway/hostgrants.go create mode 100644 core/services/addons/gateway/hostgrants_owner.go create mode 100644 core/services/addons/gateway/hostgrants_split_test.go create mode 100644 core/services/addons/gateway/hostgrants_test.go create mode 100644 core/services/addons/hostgrants/grammar_test.go diff --git a/core/services/addons/gateway/carrier.go b/core/services/addons/gateway/carrier.go index 07fabbeade..0e590d102f 100644 --- a/core/services/addons/gateway/carrier.go +++ b/core/services/addons/gateway/carrier.go @@ -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 diff --git a/core/services/addons/gateway/forwarding.go b/core/services/addons/gateway/forwarding.go index 62b5466ba0..d82a4ef860 100644 --- a/core/services/addons/gateway/forwarding.go +++ b/core/services/addons/gateway/forwarding.go @@ -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 diff --git a/core/services/addons/gateway/gateway.go b/core/services/addons/gateway/gateway.go index e5c2000201..73bb2e9951 100644 --- a/core/services/addons/gateway/gateway.go +++ b/core/services/addons/gateway/gateway.go @@ -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) diff --git a/core/services/addons/gateway/hostgrants.go b/core/services/addons/gateway/hostgrants.go new file mode 100644 index 0000000000..5ac18ad9e6 --- /dev/null +++ b/core/services/addons/gateway/hostgrants.go @@ -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-". 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 +} diff --git a/core/services/addons/gateway/hostgrants_owner.go b/core/services/addons/gateway/hostgrants_owner.go new file mode 100644 index 0000000000..0bddd7f974 --- /dev/null +++ b/core/services/addons/gateway/hostgrants_owner.go @@ -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) + } + } +} diff --git a/core/services/addons/gateway/hostgrants_split_test.go b/core/services/addons/gateway/hostgrants_split_test.go new file mode 100644 index 0000000000..f1059f14b6 --- /dev/null +++ b/core/services/addons/gateway/hostgrants_split_test.go @@ -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)) + }) +}) diff --git a/core/services/addons/gateway/hostgrants_test.go b/core/services/addons/gateway/hostgrants_test.go new file mode 100644 index 0000000000..feea03cd96 --- /dev/null +++ b/core/services/addons/gateway/hostgrants_test.go @@ -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) + } + }) +}) diff --git a/core/services/addons/gateway/library.go b/core/services/addons/gateway/library.go index 442f42cfdb..a6c31f57d4 100644 --- a/core/services/addons/gateway/library.go +++ b/core/services/addons/gateway/library.go @@ -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 } diff --git a/core/services/addons/hostgrants/grammar_test.go b/core/services/addons/hostgrants/grammar_test.go new file mode 100644 index 0000000000..2f99119dd9 --- /dev/null +++ b/core/services/addons/hostgrants/grammar_test.go @@ -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)) + } + } +} diff --git a/core/services/addons/hostgrants/policy_test.go b/core/services/addons/hostgrants/policy_test.go index b17cb461ab..86b462ea50 100644 --- a/core/services/addons/hostgrants/policy_test.go +++ b/core/services/addons/hostgrants/policy_test.go @@ -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)) - } - } -}