mirror of
https://github.com/mudler/LocalAI.git
synced 2026-11-06 12:33:16 -05:00
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:
10 files changed
+2125
-16
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user