mirror of
https://github.com/mudler/LocalAI.git
synced 2026-11-07 21:13:27 -05:00
feat(addons): add the host grants store and effective-set logic
The hostgrants package holds the owner document of host operation grants made in the UI, the UI deny rules, the blanket switch and the hints used to pre-tick a reinstall. Writes use compare-and-set on a revision and operation receipts, with atomic persist like the host settings store. The package also holds the pure policy logic: deny (file, flag, UI) over file prefix grants over exact UI grants, with identity and pending-entry checks, the grant plan (approved, declared, registered, not denied), blanket evaluation, startup reconciliation and an fsynced audit file. Assisted-by: Claude:claude-sonnet-5-5 go
This commit is contained in:
9 files changed
+2165
No files matched your search
@@ -0,0 +1,125 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
package hostgrants
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/mudler/xlog"
|
||||
)
|
||||
|
||||
// Audit events. Callers must not put secrets or file paths of the policy in
|
||||
// an entry.
|
||||
const (
|
||||
AuditGrant = "grant"
|
||||
AuditRevoke = "revoke"
|
||||
AuditEdit = "edit"
|
||||
AuditDenyAdd = "deny-add"
|
||||
AuditDenyRemove = "deny-remove"
|
||||
AuditBlanketOn = "blanket-on"
|
||||
AuditBlanketOff = "blanket-off"
|
||||
AuditBlanketGrant = "blanket-grant"
|
||||
AuditRefused = "refused"
|
||||
AuditReconcileRemove = "reconcile-removed"
|
||||
AuditRestartForGrant = "restart-for-grant"
|
||||
)
|
||||
|
||||
const maxAuditLine = 16 << 10
|
||||
|
||||
// AuditEntry is one line of the audit file.
|
||||
type AuditEntry struct {
|
||||
Time string `json:"time"`
|
||||
Event string `json:"event"`
|
||||
AddonID string `json:"addon,omitempty"`
|
||||
Ops []string `json:"ops,omitempty"`
|
||||
Source string `json:"source,omitempty"`
|
||||
Version string `json:"version,omitempty"`
|
||||
Digest string `json:"digest,omitempty"`
|
||||
OperationID string `json:"operationId,omitempty"`
|
||||
RevisionBefore uint64 `json:"revisionBefore,omitempty"`
|
||||
RevisionAfter uint64 `json:"revisionAfter,omitempty"`
|
||||
// By is the administrator ID, or "deployment" for a switch set by flag.
|
||||
By string `json:"by,omitempty"`
|
||||
// Credential is the kind of credential used, never the credential.
|
||||
Credential string `json:"credential,omitempty"`
|
||||
// Reason is the refusal or removal reason, such as not_human_surface,
|
||||
// approval_stale, blocked_by_policy, revision_conflict or
|
||||
// locked_by_deployment.
|
||||
Reason string `json:"reason,omitempty"`
|
||||
// Blanket names the switch that made a blanket grant, such as
|
||||
// "official@rev3".
|
||||
Blanket string `json:"blanket,omitempty"`
|
||||
}
|
||||
|
||||
// Audit is the append-only JSONL file beside the document. Every entry is
|
||||
// fsynced and also logged as one info line.
|
||||
type Audit struct {
|
||||
mu sync.Mutex
|
||||
f *os.File
|
||||
}
|
||||
|
||||
// OpenAudit opens or creates the audit file for append. A symlink is refused.
|
||||
func OpenAudit(path string) (*Audit, error) {
|
||||
f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND|syscall.O_NOFOLLOW, 0600) // #nosec G304 -- fixed file in the guarded owner root; NOFOLLOW rejects symlinks.
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = f.Chmod(0600); err != nil {
|
||||
_ = f.Close()
|
||||
return nil, err
|
||||
}
|
||||
return &Audit{f: f}, nil
|
||||
}
|
||||
|
||||
// Append writes one entry and syncs it. A nil Audit does nothing, so tests and
|
||||
// a locked deployment need no special case. The time is set when empty.
|
||||
func (a *Audit) Append(e AuditEntry) error {
|
||||
if a == nil {
|
||||
return nil
|
||||
}
|
||||
if e.Event == "" {
|
||||
return invalid("audit event")
|
||||
}
|
||||
if e.Time == "" {
|
||||
e.Time = time.Now().UTC().Format(time.RFC3339)
|
||||
}
|
||||
b, err := json.Marshal(e)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(b) >= maxAuditLine {
|
||||
return invalid("audit entry too large")
|
||||
}
|
||||
b = append(b, '\n')
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
if a.f == nil {
|
||||
return ErrUnavailable
|
||||
}
|
||||
// O_APPEND makes the single write atomic with respect to other writers.
|
||||
if _, err = a.f.Write(b); err == nil {
|
||||
err = a.f.Sync()
|
||||
}
|
||||
xlog.Info("Addon host grant audit", "event", e.Event, "addon", e.AddonID, "ops", e.Ops, "source", e.Source,
|
||||
"operationId", e.OperationID, "by", e.By, "reason", e.Reason, "revision", e.RevisionAfter)
|
||||
return err
|
||||
}
|
||||
|
||||
// Close closes the file.
|
||||
func (a *Audit) Close() error {
|
||||
if a == nil {
|
||||
return nil
|
||||
}
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
if a.f == nil {
|
||||
return nil
|
||||
}
|
||||
err := a.f.Close()
|
||||
a.f = nil
|
||||
return errors.Join(err)
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
package hostgrants
|
||||
|
||||
// BlanketInput describes one install or upgrade to the blanket evaluator.
|
||||
type BlanketInput struct {
|
||||
AddonID string
|
||||
// Official is true only when the package comes from the built-in
|
||||
// official library AND its receipt shows the official signer. The
|
||||
// source ID alone is not enough, since an administrator names sources
|
||||
// freely. The caller computes it from the verified receipt.
|
||||
Official bool
|
||||
Required, Optional []string
|
||||
}
|
||||
|
||||
// BlanketResult says what the switch does for one install or upgrade.
|
||||
type BlanketResult struct {
|
||||
// Applies is false when the switch is off or the package is outside its
|
||||
// scope. Plan is then empty and no grant is written.
|
||||
Applies bool
|
||||
// Revision is the switch revision that produces the grants; it goes into
|
||||
// each OpGrant.BlanketRevision and the audit entry.
|
||||
Revision uint64
|
||||
// Plan holds the grants (required operations, plus optional ones when
|
||||
// IncludeOptional is set) and the refusals. BlockedRequired non-empty
|
||||
// means the install is refused as blocked_by_policy: blanket never
|
||||
// writes a grant for a denied operation.
|
||||
Plan Plan
|
||||
}
|
||||
|
||||
// InScope reports whether the switch covers a package.
|
||||
func (b Blanket) InScope(official bool) bool {
|
||||
switch b.Mode {
|
||||
case BlanketAny:
|
||||
return true
|
||||
case BlanketOfficial:
|
||||
return official
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// EvaluateBlanket decides what the blanket switch grants. It grants only
|
||||
// declared, registered names, required ones unless IncludeOptional is set,
|
||||
// and never an operation a deny rule blocks. The effective switch is the one
|
||||
// in doc, unless the deployment fixes the mode, which the caller passes as
|
||||
// override ("" keeps the document's mode).
|
||||
func (f Floor) EvaluateBlanket(doc Snapshot, c Context, override string, in BlanketInput) BlanketResult {
|
||||
b := doc.Blanket
|
||||
if override != "" {
|
||||
b.Mode = override
|
||||
}
|
||||
if !b.InScope(in.Official) {
|
||||
return BlanketResult{Revision: b.Revision}
|
||||
}
|
||||
wanted := append([]string(nil), in.Required...)
|
||||
if b.IncludeOptional {
|
||||
wanted = append(wanted, in.Optional...)
|
||||
}
|
||||
return BlanketResult{
|
||||
Applies: true,
|
||||
Revision: b.Revision,
|
||||
Plan: f.Plan(doc, c, PlanInput{
|
||||
AddonID: in.AddonID, Required: in.Required, Optional: in.Optional, Wanted: wanted,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
// MergeGrants returns entry with the named operations added, each stamped with
|
||||
// the given metadata. Existing operations keep their own source: an explicit
|
||||
// grant is never relabelled blanket. The identity, generation and operation ID
|
||||
// of the entry become those of the new write.
|
||||
func MergeGrants(entry AddonGrants, identity string, generation uint64, operationID string, add []OpGrant) AddonGrants {
|
||||
out := AddonGrants{Identity: identity, Generation: generation, OperationID: operationID}
|
||||
have := map[string]bool{}
|
||||
// An entry for another identity is ineffective, so it is not carried over.
|
||||
if entry.Identity == identity {
|
||||
for _, op := range entry.Ops {
|
||||
out.Ops = append(out.Ops, op)
|
||||
have[op.Name] = true
|
||||
}
|
||||
}
|
||||
for _, op := range add {
|
||||
if !have[op.Name] {
|
||||
out.Ops = append(out.Ops, op)
|
||||
have[op.Name] = true
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// KeepAsOwn relabels the blanket grants of an entry as the administrator's
|
||||
// own, so a later "revoke blanket grants" no longer removes them.
|
||||
func KeepAsOwn(entry AddonGrants, at, by string) AddonGrants {
|
||||
out := entry
|
||||
out.Ops = make([]OpGrant, len(entry.Ops))
|
||||
for i, op := range entry.Ops {
|
||||
if op.Source == SourceBlanket {
|
||||
op.Source, op.BlanketRevision, op.At, op.By = SourceEdit, 0, at, by
|
||||
}
|
||||
out.Ops[i] = op
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,391 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package hostgrants owns the owner document of host operation grants that an
|
||||
// administrator makes in the UI, the deny rules and the blanket approval
|
||||
// switch. It also holds the pure policy logic that combines that document
|
||||
// with the startup policy file and flags into the operations an add-on may
|
||||
// call. The document lives in the owner policy folder beside, and separate
|
||||
// from, the host settings document, so it is never projected to a frontend.
|
||||
//
|
||||
// The package is not an authorization boundary by itself: only the
|
||||
// authenticated host owner may call Store.Update.
|
||||
package hostgrants
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// SchemaVersion is the version of the stored document.
|
||||
const SchemaVersion = 1
|
||||
|
||||
// Limits of the document. The add-on and deny limits match the policy file.
|
||||
const (
|
||||
MaxAddons = 128
|
||||
MaxOpsPerAddon = 32
|
||||
MaxDenyKeys = 128
|
||||
MaxDenyEntries = 32
|
||||
MaxHintsPerAddon = 32
|
||||
MaxReceipts = 256
|
||||
maxDocumentBytes = 4 << 20
|
||||
maxTextLen = 256
|
||||
maxOperationIDLen = 128
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalid = errors.New("invalid addon host grants")
|
||||
ErrConflict = errors.New("addon host grants revision conflict")
|
||||
ErrOperationMismatch = errors.New("addon host grants operation body mismatch")
|
||||
ErrUnavailable = errors.New("addon host grants unavailable")
|
||||
ErrUncertain = errors.New("addon host grants durability uncertain; reopen and reconcile operation")
|
||||
)
|
||||
|
||||
var (
|
||||
addonIDPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*$`)
|
||||
opNamePattern = regexp.MustCompile(`^[a-z]+(\.[a-z]+)+$`)
|
||||
prefixPattern = regexp.MustCompile(`^[a-z]+\.([a-z]+\.)*$`)
|
||||
hexDigestFormat = regexp.MustCompile(`^[0-9a-f]{64}$`)
|
||||
)
|
||||
|
||||
// Source says why an operation is granted. The first three are an explicit
|
||||
// administrator choice; Blanket was made by the blanket switch.
|
||||
type Source string
|
||||
|
||||
const (
|
||||
SourceInstall Source = "install"
|
||||
SourceUpgrade Source = "upgrade"
|
||||
SourceEdit Source = "edit"
|
||||
SourceBlanket Source = "blanket"
|
||||
)
|
||||
|
||||
func (s Source) valid() bool {
|
||||
switch s {
|
||||
case SourceInstall, SourceUpgrade, SourceEdit, SourceBlanket:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Blanket modes.
|
||||
const (
|
||||
BlanketOff = "off"
|
||||
BlanketOfficial = "official"
|
||||
BlanketAny = "any"
|
||||
)
|
||||
|
||||
// OpGrant is one exact operation granted to an add-on. Grants are never
|
||||
// prefixes: the UI can grant only what the manifest names.
|
||||
type OpGrant struct {
|
||||
Name string `json:"name"`
|
||||
Source Source `json:"source"`
|
||||
At string `json:"at,omitempty"`
|
||||
By string `json:"by,omitempty"`
|
||||
Version string `json:"version,omitempty"`
|
||||
// BlanketRevision names the blanket switch revision that produced a
|
||||
// grant with Source blanket, so the group can be told apart and revoked.
|
||||
BlanketRevision uint64 `json:"blanketRevision,omitempty"`
|
||||
}
|
||||
|
||||
// AddonGrants is the UI-made grant entry of one add-on.
|
||||
type AddonGrants struct {
|
||||
// Identity is the trusted identity of the installed package. An entry
|
||||
// whose identity differs from the current receipt is ineffective.
|
||||
Identity string `json:"identity"`
|
||||
Generation uint64 `json:"generation"`
|
||||
// OperationID is the lifecycle operation that created the entry. The
|
||||
// entry takes effect only once that operation completed.
|
||||
OperationID string `json:"operationId"`
|
||||
Ops []OpGrant `json:"ops"`
|
||||
}
|
||||
|
||||
// Blanket is the blanket approval switch. Revision counts changes of Mode or
|
||||
// IncludeOptional and is assigned by the store.
|
||||
type Blanket struct {
|
||||
Mode string `json:"mode"`
|
||||
IncludeOptional bool `json:"includeOptional"`
|
||||
Revision uint64 `json:"revision"`
|
||||
ChangedAt string `json:"changedAt,omitempty"`
|
||||
ChangedBy string `json:"changedBy,omitempty"`
|
||||
}
|
||||
|
||||
// Snapshot is the visible content of the document at one revision.
|
||||
type Snapshot struct {
|
||||
SchemaVersion int `json:"schemaVersion"`
|
||||
Revision uint64 `json:"revision"`
|
||||
Blanket Blanket `json:"blanket"`
|
||||
Grants map[string]AddonGrants `json:"grants"`
|
||||
// Deny holds the UI deny rules: add-on ID or "*" to prefixes, exact
|
||||
// operation names, or the single token "*".
|
||||
Deny map[string][]string `json:"deny"`
|
||||
// Hints are non-authoritative: operations granted before an uninstall,
|
||||
// used only to pre-tick the dialog on reinstall.
|
||||
Hints map[string][]string `json:"hints"`
|
||||
}
|
||||
|
||||
type receipt struct {
|
||||
Body string `json:"body"`
|
||||
Revision uint64 `json:"revision"`
|
||||
}
|
||||
|
||||
type document struct {
|
||||
Snapshot
|
||||
Receipts map[string]receipt `json:"receipts"`
|
||||
}
|
||||
|
||||
func emptySnapshot() Snapshot {
|
||||
return Snapshot{
|
||||
SchemaVersion: SchemaVersion,
|
||||
Blanket: Blanket{Mode: BlanketOff},
|
||||
Grants: map[string]AddonGrants{},
|
||||
Deny: map[string][]string{},
|
||||
Hints: map[string][]string{},
|
||||
}
|
||||
}
|
||||
|
||||
// Clone returns a deep copy that the caller may modify.
|
||||
func (s Snapshot) Clone() Snapshot {
|
||||
out := s
|
||||
out.Grants = make(map[string]AddonGrants, len(s.Grants))
|
||||
for id, g := range s.Grants {
|
||||
g.Ops = slices.Clone(g.Ops)
|
||||
out.Grants[id] = g
|
||||
}
|
||||
out.Deny = cloneLists(s.Deny)
|
||||
out.Hints = cloneLists(s.Hints)
|
||||
return out
|
||||
}
|
||||
|
||||
func cloneLists(in map[string][]string) map[string][]string {
|
||||
out := make(map[string][]string, len(in))
|
||||
for k, v := range in {
|
||||
out[k] = slices.Clone(v)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Patch is one atomic change. Parts apply in this order: RevokeBlanket,
|
||||
// Addons, Blanket, Deny, Hints. A nil pointer or map leaves its part alone.
|
||||
type Patch struct {
|
||||
// RevokeBlanket drops every op with source blanket from every add-on,
|
||||
// removing entries left without ops.
|
||||
RevokeBlanket bool `json:"revokeBlanket,omitempty"`
|
||||
// Addons replaces the whole entry of an add-on; a nil value deletes it.
|
||||
// Callers read the snapshot, edit one entry and send it back; the
|
||||
// expected revision makes that safe.
|
||||
Addons map[string]*AddonGrants `json:"addons,omitempty"`
|
||||
// Blanket sets the switch. The store assigns Revision: it grows by one
|
||||
// when Mode or IncludeOptional change.
|
||||
Blanket *Blanket `json:"blanket,omitempty"`
|
||||
// Deny replaces the whole UI deny rule set.
|
||||
Deny *map[string][]string `json:"deny,omitempty"`
|
||||
// Hints replaces the hint of an add-on; an empty list deletes it.
|
||||
Hints map[string][]string `json:"hints,omitempty"`
|
||||
}
|
||||
|
||||
func apply(base Snapshot, p Patch) (Snapshot, error) {
|
||||
next := base.Clone()
|
||||
if p.RevokeBlanket {
|
||||
for id, g := range next.Grants {
|
||||
kept := g.Ops[:0]
|
||||
for _, op := range g.Ops {
|
||||
if op.Source != SourceBlanket {
|
||||
kept = append(kept, op)
|
||||
}
|
||||
}
|
||||
if len(kept) == 0 {
|
||||
delete(next.Grants, id)
|
||||
continue
|
||||
}
|
||||
g.Ops = kept
|
||||
next.Grants[id] = g
|
||||
}
|
||||
}
|
||||
for id, g := range p.Addons {
|
||||
if g == nil {
|
||||
delete(next.Grants, id)
|
||||
continue
|
||||
}
|
||||
entry := *g
|
||||
entry.Ops = slices.Clone(g.Ops)
|
||||
next.Grants[id] = entry
|
||||
}
|
||||
if p.Blanket != nil {
|
||||
b := *p.Blanket
|
||||
if b.Mode != next.Blanket.Mode || b.IncludeOptional != next.Blanket.IncludeOptional {
|
||||
b.Revision = next.Blanket.Revision + 1
|
||||
} else {
|
||||
b.Revision = next.Blanket.Revision
|
||||
}
|
||||
next.Blanket = b
|
||||
}
|
||||
if p.Deny != nil {
|
||||
next.Deny = cloneLists(*p.Deny)
|
||||
}
|
||||
for id, ops := range p.Hints {
|
||||
if len(ops) == 0 {
|
||||
delete(next.Hints, id)
|
||||
continue
|
||||
}
|
||||
next.Hints[id] = slices.Clone(ops)
|
||||
}
|
||||
normalize(&next)
|
||||
if err := validate(next); err != nil {
|
||||
return Snapshot{}, err
|
||||
}
|
||||
return next, nil
|
||||
}
|
||||
|
||||
// normalize sorts what has no meaningful order, so equal content has one form.
|
||||
func normalize(s *Snapshot) {
|
||||
for id, g := range s.Grants {
|
||||
sort.Slice(g.Ops, func(i, j int) bool { return g.Ops[i].Name < g.Ops[j].Name })
|
||||
s.Grants[id] = g
|
||||
}
|
||||
for id, ops := range s.Hints {
|
||||
sort.Strings(ops)
|
||||
s.Hints[id] = ops
|
||||
}
|
||||
}
|
||||
|
||||
func invalid(format string, args ...any) error {
|
||||
return fmt.Errorf("%w: %s", ErrInvalid, fmt.Sprintf(format, args...))
|
||||
}
|
||||
|
||||
func validText(s string) bool {
|
||||
if len(s) > maxTextLen {
|
||||
return false
|
||||
}
|
||||
for _, r := range s {
|
||||
if r < 0x20 || r == 0x7f {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func validTime(s string) bool {
|
||||
if s == "" {
|
||||
return true
|
||||
}
|
||||
_, err := time.Parse(time.RFC3339, s)
|
||||
return err == nil && validText(s)
|
||||
}
|
||||
|
||||
func validOperationID(id string) bool {
|
||||
return len(id) > 0 && len(id) <= maxOperationIDLen && strings.TrimSpace(id) == id && validText(id)
|
||||
}
|
||||
|
||||
// ValidDenyEntry reports whether s may appear in a deny list: a prefix such
|
||||
// as "egress.", an exact operation name such as "workspace.admin", or "*".
|
||||
func ValidDenyEntry(s string) bool {
|
||||
return s == "*" || prefixPattern.MatchString(s) || opNamePattern.MatchString(s)
|
||||
}
|
||||
|
||||
// ValidateDeny checks a deny rule set against the limits of the policy file.
|
||||
func ValidateDeny(deny map[string][]string) error {
|
||||
if len(deny) > MaxDenyKeys {
|
||||
return invalid("deny exceeds %d add-ons", MaxDenyKeys)
|
||||
}
|
||||
for id, entries := range deny {
|
||||
if id != "*" && !addonIDPattern.MatchString(id) {
|
||||
return invalid("deny key %q", id)
|
||||
}
|
||||
if len(entries) > MaxDenyEntries {
|
||||
return invalid("deny for %q exceeds %d entries", id, MaxDenyEntries)
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, e := range entries {
|
||||
if !ValidDenyEntry(e) || seen[e] {
|
||||
return invalid("deny entry %q for %q", e, id)
|
||||
}
|
||||
seen[e] = true
|
||||
}
|
||||
if seen["*"] && len(entries) != 1 {
|
||||
return invalid(`deny "*" must be the only entry of %q`, id)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validate(s Snapshot) error {
|
||||
if s.SchemaVersion != SchemaVersion {
|
||||
return invalid("schema version")
|
||||
}
|
||||
switch s.Blanket.Mode {
|
||||
case BlanketOff, BlanketOfficial, BlanketAny:
|
||||
default:
|
||||
return invalid("blanket mode")
|
||||
}
|
||||
if !validText(s.Blanket.ChangedBy) || !validTime(s.Blanket.ChangedAt) {
|
||||
return invalid("blanket metadata")
|
||||
}
|
||||
if len(s.Grants) > MaxAddons {
|
||||
return invalid("more than %d add-ons", MaxAddons)
|
||||
}
|
||||
for id, g := range s.Grants {
|
||||
if !addonIDPattern.MatchString(id) {
|
||||
return invalid("add-on ID %q", id)
|
||||
}
|
||||
if g.Identity == "" || !validText(g.Identity) || !validOperationID(g.OperationID) {
|
||||
return invalid("entry of %q", id)
|
||||
}
|
||||
if len(g.Ops) == 0 || len(g.Ops) > MaxOpsPerAddon {
|
||||
return invalid("%q must hold between 1 and %d operations", id, MaxOpsPerAddon)
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, op := range g.Ops {
|
||||
if !opNamePattern.MatchString(op.Name) || seen[op.Name] || !op.Source.valid() {
|
||||
return invalid("operation %q of %q", op.Name, id)
|
||||
}
|
||||
seen[op.Name] = true
|
||||
if !validText(op.By) || !validText(op.Version) || !validTime(op.At) {
|
||||
return invalid("metadata of %q for %q", op.Name, id)
|
||||
}
|
||||
if (op.Source == SourceBlanket) != (op.BlanketRevision != 0) || op.BlanketRevision > s.Blanket.Revision {
|
||||
return invalid("blanket revision of %q for %q", op.Name, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := ValidateDeny(s.Deny); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(s.Hints) > MaxAddons {
|
||||
return invalid("more than %d hints", MaxAddons)
|
||||
}
|
||||
for id, ops := range s.Hints {
|
||||
if !addonIDPattern.MatchString(id) || len(ops) > MaxHintsPerAddon {
|
||||
return invalid("hint of %q", id)
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, op := range ops {
|
||||
if !opNamePattern.MatchString(op) || seen[op] {
|
||||
return invalid("hint operation %q of %q", op, id)
|
||||
}
|
||||
seen[op] = true
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateDocument(d document) error {
|
||||
if d.Revision == 0 {
|
||||
return invalid("revision")
|
||||
}
|
||||
if err := validate(d.Snapshot); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(d.Receipts) > MaxReceipts {
|
||||
return invalid("too many receipts")
|
||||
}
|
||||
for id, r := range d.Receipts {
|
||||
if !validOperationID(id) || !hexDigestFormat.MatchString(r.Body) || r.Revision < 1 || r.Revision > d.Revision {
|
||||
return invalid("receipt %q", id)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
package hostgrants
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Reason says why an operation is not allowed. The strings are the ones of
|
||||
// hostops.Reason in the design; the caller converts. An add-on must never see
|
||||
// them: only logs, audit lines and the administrator page do.
|
||||
type Reason string
|
||||
|
||||
const (
|
||||
ReasonNone Reason = ""
|
||||
ReasonNotGranted Reason = "not_granted"
|
||||
ReasonDeniedByFile Reason = "denied_by_policy_file"
|
||||
ReasonDeniedByAdmin Reason = "denied_by_admin"
|
||||
ReasonIdentityMismatch Reason = "identity_mismatch"
|
||||
)
|
||||
|
||||
// SourceDeployment labels a grant that comes from the policy file or flags.
|
||||
const SourceDeployment Source = "deployment"
|
||||
|
||||
// Decision is the answer for one add-on and operation.
|
||||
type Decision struct {
|
||||
Allowed bool
|
||||
Reason Reason
|
||||
// Source is set when Allowed: deployment, install, upgrade, edit or
|
||||
// blanket. When several sources grant the same operation the display
|
||||
// precedence applies: deployment, then explicit UI, then blanket.
|
||||
Source Source
|
||||
}
|
||||
|
||||
// Context carries what the evaluator cannot know from the document.
|
||||
type Context struct {
|
||||
// Identity is the trusted identity of the installed package of the add-on
|
||||
// being evaluated. Empty means unknown: UI entries then do not count.
|
||||
Identity string
|
||||
// Completed reports whether a lifecycle operation completed in the
|
||||
// journal. A UI entry counts only once its operation completed. Nil
|
||||
// treats every entry as completed.
|
||||
Completed func(operationID string) bool
|
||||
// Registered reports whether the host registers an operation. Nil
|
||||
// registers every name.
|
||||
Registered func(op string) bool
|
||||
}
|
||||
|
||||
// Floor is the startup policy of the file and flags. It is read-only after
|
||||
// construction.
|
||||
type Floor struct {
|
||||
// Grants maps an add-on ID to operation prefixes (file and flat flag).
|
||||
Grants map[string][]string
|
||||
// Deny maps an add-on ID, or "*", to deny entries (file and flag).
|
||||
Deny map[string][]string
|
||||
}
|
||||
|
||||
// matchDeny reports whether any rule of rules names op for addonID.
|
||||
func matchDeny(rules map[string][]string, addonID, op string) bool {
|
||||
for _, key := range [...]string{addonID, "*"} {
|
||||
for _, e := range rules[key] {
|
||||
switch {
|
||||
case e == "*":
|
||||
return true
|
||||
case strings.HasSuffix(e, "."):
|
||||
if strings.HasPrefix(op, e) {
|
||||
return true
|
||||
}
|
||||
case e == op:
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Denied reports whether op is denied for the add-on and by which layer. File
|
||||
// and flag deny come before the deny set in the UI document.
|
||||
func (f Floor) Denied(doc Snapshot, addonID, op string) (bool, Reason) {
|
||||
if matchDeny(f.Deny, addonID, op) {
|
||||
return true, ReasonDeniedByFile
|
||||
}
|
||||
if matchDeny(doc.Deny, addonID, op) {
|
||||
return true, ReasonDeniedByAdmin
|
||||
}
|
||||
return false, ReasonNone
|
||||
}
|
||||
|
||||
func (f Floor) floorGrants(addonID, op string) bool {
|
||||
for _, p := range f.Grants[addonID] {
|
||||
if strings.HasPrefix(op, p) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Decide computes: denied by file or flag, denied in the UI, granted by the
|
||||
// floor (prefix), granted by a UI entry (equality, identity checked), else not
|
||||
// granted. Deny always wins, whatever else grants the operation.
|
||||
func (f Floor) Decide(doc Snapshot, c Context, addonID, op string) Decision {
|
||||
if denied, why := f.Denied(doc, addonID, op); denied {
|
||||
return Decision{Reason: why}
|
||||
}
|
||||
if c.Registered != nil && !c.Registered(op) {
|
||||
return Decision{Reason: ReasonNotGranted}
|
||||
}
|
||||
if f.floorGrants(addonID, op) {
|
||||
return Decision{Allowed: true, Source: SourceDeployment}
|
||||
}
|
||||
entry, ok := doc.Grants[addonID]
|
||||
if !ok {
|
||||
return Decision{Reason: ReasonNotGranted}
|
||||
}
|
||||
i := slices.IndexFunc(entry.Ops, func(g OpGrant) bool { return g.Name == op })
|
||||
if i < 0 {
|
||||
return Decision{Reason: ReasonNotGranted}
|
||||
}
|
||||
if c.Identity == "" || entry.Identity != c.Identity {
|
||||
return Decision{Reason: ReasonIdentityMismatch}
|
||||
}
|
||||
if c.Completed != nil && !c.Completed(entry.OperationID) {
|
||||
return Decision{Reason: ReasonNotGranted}
|
||||
}
|
||||
return Decision{Allowed: true, Source: entry.Ops[i].Source}
|
||||
}
|
||||
|
||||
// OpStatus is a Decision for one named operation.
|
||||
type OpStatus struct {
|
||||
Op string
|
||||
Decision
|
||||
}
|
||||
|
||||
// Effective evaluates each candidate (usually the operations the manifest
|
||||
// declares) for the add-on, in the order given.
|
||||
func (f Floor) Effective(doc Snapshot, c Context, addonID string, candidates []string) []OpStatus {
|
||||
out := make([]OpStatus, 0, len(candidates))
|
||||
for _, op := range candidates {
|
||||
out = append(out, OpStatus{Op: op, Decision: f.Decide(doc, c, addonID, op)})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Entitled reports whether the add-on may call anything at all: it has a file
|
||||
// or flag grant that no add-on-wide deny ("*") removes, or an effective UI
|
||||
// entry. A narrower deny cannot make this false for a prefix grant, because
|
||||
// part of the prefix may stay callable; the per-call check is Decide.
|
||||
func (f Floor) Entitled(doc Snapshot, c Context, addonID string) bool {
|
||||
if len(f.Grants[addonID]) > 0 && !denyAll(f.Deny, addonID) && !denyAll(doc.Deny, addonID) {
|
||||
return true
|
||||
}
|
||||
for _, op := range doc.Grants[addonID].Ops {
|
||||
if f.Decide(doc, c, addonID, op.Name).Allowed {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func denyAll(rules map[string][]string, addonID string) bool {
|
||||
return slices.Contains(rules[addonID], "*") || slices.Contains(rules["*"], "*")
|
||||
}
|
||||
|
||||
// PlanInput asks which of the wanted operations may be granted now.
|
||||
type PlanInput struct {
|
||||
AddonID string
|
||||
// Required and Optional are the operations of the verified manifest.
|
||||
Required, Optional []string
|
||||
// Wanted is what the administrator approved, or what the blanket switch
|
||||
// picked. Only declared, registered, not denied, not yet held names are
|
||||
// granted; anything else in Wanted is ignored.
|
||||
Wanted []string
|
||||
}
|
||||
|
||||
// Plan is the outcome of PlanInput. Every list keeps the order of its input
|
||||
// and holds each name once.
|
||||
type Plan struct {
|
||||
// Grant is the set to write: wanted, declared, registered, not denied
|
||||
// and not yet held.
|
||||
Grant []string
|
||||
// Held are declared operations already allowed by any source.
|
||||
Held []string
|
||||
// Denied are declared operations that a deny rule blocks.
|
||||
Denied []string
|
||||
// Unsupported are declared operations the host does not register.
|
||||
Unsupported []string
|
||||
// BlockedRequired are required operations that are denied; the install
|
||||
// is refused as blocked_by_policy.
|
||||
BlockedRequired []string
|
||||
// MissingRequired are required operations that are neither granted nor
|
||||
// held nor denied, including unsupported ones; the install is refused
|
||||
// as not approved.
|
||||
MissingRequired []string
|
||||
}
|
||||
|
||||
// Plan applies the grant rule of the design: approved ∩ declared ∩ registered,
|
||||
// minus deny, minus what is already held. It never reads Wanted outside the
|
||||
// declared names, so a lying approval cannot widen anything.
|
||||
func (f Floor) Plan(doc Snapshot, c Context, in PlanInput) Plan {
|
||||
var p Plan
|
||||
wanted := map[string]bool{}
|
||||
for _, w := range in.Wanted {
|
||||
wanted[w] = true
|
||||
}
|
||||
required := map[string]bool{}
|
||||
for _, r := range in.Required {
|
||||
required[r] = true
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
declared := append(slices.Clone(in.Required), in.Optional...)
|
||||
for _, op := range declared {
|
||||
if seen[op] {
|
||||
continue
|
||||
}
|
||||
seen[op] = true
|
||||
if denied, _ := f.Denied(doc, in.AddonID, op); denied {
|
||||
p.Denied = append(p.Denied, op)
|
||||
if required[op] {
|
||||
p.BlockedRequired = append(p.BlockedRequired, op)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if c.Registered != nil && !c.Registered(op) {
|
||||
p.Unsupported = append(p.Unsupported, op)
|
||||
if required[op] {
|
||||
p.MissingRequired = append(p.MissingRequired, op)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if f.Decide(doc, c, in.AddonID, op).Allowed {
|
||||
p.Held = append(p.Held, op)
|
||||
continue
|
||||
}
|
||||
if wanted[op] {
|
||||
p.Grant = append(p.Grant, op)
|
||||
continue
|
||||
}
|
||||
if required[op] {
|
||||
p.MissingRequired = append(p.MissingRequired, op)
|
||||
}
|
||||
}
|
||||
return p
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
package hostgrants
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/mudler/LocalAI/core/config"
|
||||
)
|
||||
|
||||
const (
|
||||
op = "workspace.commit"
|
||||
ident = "id-1"
|
||||
addon = "sandbox"
|
||||
other = "web"
|
||||
opFam = "workspace."
|
||||
opName = "workspace.commit"
|
||||
)
|
||||
|
||||
func uiEntry(source Source, identity string, ops ...string) AddonGrants {
|
||||
g := AddonGrants{Identity: identity, Generation: 1, OperationID: "op-1"}
|
||||
for _, o := range ops {
|
||||
og := OpGrant{Name: o, Source: source}
|
||||
if source == SourceBlanket {
|
||||
og.BlanketRevision = 1
|
||||
}
|
||||
g.Ops = append(g.Ops, og)
|
||||
}
|
||||
return g
|
||||
}
|
||||
|
||||
// The precedence table is generated from independent boolean dimensions, and
|
||||
// the expectation is written as a plain ordered list of rules so that it does
|
||||
// not share logic with the code under test.
|
||||
func TestPrecedenceMatrix(t *testing.T) {
|
||||
type dim struct{ fileDeny, uiDeny, floor, ui, blanket, identityOK bool }
|
||||
for bits := 0; bits < 1<<6; bits++ {
|
||||
d := dim{bits&1 != 0, bits&2 != 0, bits&4 != 0, bits&8 != 0, bits&16 != 0, bits&32 != 0}
|
||||
name := fmt.Sprintf("%+v", d)
|
||||
floor := Floor{Grants: map[string][]string{}, Deny: map[string][]string{}}
|
||||
doc := emptySnapshot()
|
||||
if d.fileDeny {
|
||||
floor.Deny[addon] = []string{opName}
|
||||
}
|
||||
if d.uiDeny {
|
||||
doc.Deny["*"] = []string{opFam}
|
||||
}
|
||||
if d.floor {
|
||||
floor.Grants[addon] = []string{opFam}
|
||||
}
|
||||
entryIdentity := ident
|
||||
if !d.identityOK {
|
||||
entryIdentity = "other-package"
|
||||
}
|
||||
switch {
|
||||
case d.ui:
|
||||
doc.Grants[addon] = uiEntry(SourceInstall, entryIdentity, opName)
|
||||
case d.blanket:
|
||||
doc.Grants[addon] = uiEntry(SourceBlanket, entryIdentity, opName)
|
||||
}
|
||||
got := floor.Decide(doc, Context{Identity: ident}, addon, opName)
|
||||
|
||||
var want Decision
|
||||
switch {
|
||||
case d.fileDeny:
|
||||
want = Decision{Reason: ReasonDeniedByFile}
|
||||
case d.uiDeny:
|
||||
want = Decision{Reason: ReasonDeniedByAdmin}
|
||||
case d.floor:
|
||||
want = Decision{Allowed: true, Source: SourceDeployment}
|
||||
case (d.ui || d.blanket) && !d.identityOK:
|
||||
want = Decision{Reason: ReasonIdentityMismatch}
|
||||
case d.ui:
|
||||
want = Decision{Allowed: true, Source: SourceInstall}
|
||||
case d.blanket:
|
||||
want = Decision{Allowed: true, Source: SourceBlanket}
|
||||
default:
|
||||
want = Decision{Reason: ReasonNotGranted}
|
||||
}
|
||||
if got != want {
|
||||
t.Errorf("%s: got %+v, want %+v", name, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDenyMatching(t *testing.T) {
|
||||
cases := []struct {
|
||||
rules map[string][]string
|
||||
addon string
|
||||
op string
|
||||
want bool
|
||||
}{
|
||||
{map[string][]string{"*": {"egress."}}, "a", "egress.check", true},
|
||||
{map[string][]string{"*": {"egress."}}, "a", "egressx.check", false},
|
||||
{map[string][]string{"a": {"egress."}}, "b", "egress.check", false},
|
||||
{map[string][]string{"a": {"workspace.admin"}}, "a", "workspace.admin", true},
|
||||
{map[string][]string{"a": {"workspace.admin"}}, "a", "workspace.admin2", false},
|
||||
{map[string][]string{"a": {"workspace.admin"}}, "a", "workspace.adminx", false},
|
||||
{map[string][]string{"a": {"workspace.admin"}}, "a", "workspace.commit", false},
|
||||
{map[string][]string{"a": {"*"}}, "a", "anything.at.all", true},
|
||||
{map[string][]string{"a": {"*"}}, "b", "anything.at.all", false},
|
||||
{map[string][]string{"*": {"*"}}, "b", "anything.at.all", true},
|
||||
{map[string][]string{"a": {"connector.file."}}, "a", "connector.file.put", true},
|
||||
{map[string][]string{"a": {"connector.file."}}, "a", "connector.inbound", false},
|
||||
{nil, "a", "x.y", false},
|
||||
}
|
||||
for i, c := range cases {
|
||||
if got := matchDeny(c.rules, c.addon, c.op); got != c.want {
|
||||
t.Errorf("case %d (%v %s %s): got %v", i, c.rules, c.addon, c.op, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGrantsAreEqualityUIAndPrefixFloor(t *testing.T) {
|
||||
floor := Floor{Grants: map[string][]string{addon: {"egress."}}}
|
||||
doc := emptySnapshot()
|
||||
doc.Grants[addon] = uiEntry(SourceInstall, ident, "workspace.commit")
|
||||
c := Context{Identity: ident}
|
||||
if !floor.Decide(doc, c, addon, "egress.audit").Allowed {
|
||||
t.Error("a floor prefix grants every operation of the family")
|
||||
}
|
||||
if floor.Decide(doc, c, addon, "workspace.materialize").Allowed {
|
||||
t.Error("a UI grant is one exact operation, not its family")
|
||||
}
|
||||
if floor.Decide(doc, c, addon, "workspace.commit.x").Allowed {
|
||||
t.Error("a UI grant never matches by prefix")
|
||||
}
|
||||
if floor.Decide(doc, c, other, "workspace.commit").Allowed || floor.Decide(doc, c, other, "egress.audit").Allowed {
|
||||
t.Error("add-ons must not see each other's grants")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIdentityAndPendingEntries(t *testing.T) {
|
||||
floor := Floor{}
|
||||
doc := emptySnapshot()
|
||||
doc.Grants[addon] = uiEntry(SourceInstall, ident, opName)
|
||||
if d := floor.Decide(doc, Context{}, addon, opName); d.Allowed || d.Reason != ReasonIdentityMismatch {
|
||||
t.Errorf("unknown identity fails closed: %+v", d)
|
||||
}
|
||||
done := map[string]bool{}
|
||||
c := Context{Identity: ident, Completed: func(id string) bool { return done[id] }}
|
||||
if d := floor.Decide(doc, c, addon, opName); d.Allowed || d.Reason != ReasonNotGranted {
|
||||
t.Errorf("a pending entry is not effective: %+v", d)
|
||||
}
|
||||
done["op-1"] = true
|
||||
if !floor.Decide(doc, c, addon, opName).Allowed {
|
||||
t.Error("a completed entry is effective")
|
||||
}
|
||||
// A deny added after the grant wins at once, with no write to the entry.
|
||||
floor.Deny = map[string][]string{"*": {"workspace."}}
|
||||
if d := floor.Decide(doc, c, addon, opName); d.Allowed || d.Reason != ReasonDeniedByFile {
|
||||
t.Errorf("a later deny wins: %+v", d)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistryBoundsEveryGrant(t *testing.T) {
|
||||
floor := Floor{Grants: map[string][]string{addon: {"workspace."}}}
|
||||
doc := emptySnapshot()
|
||||
c := Context{Identity: ident, Registered: func(op string) bool { return op == "workspace.commit" }}
|
||||
if floor.Decide(doc, c, addon, "workspace.future").Allowed {
|
||||
t.Error("an operation the host does not register is never allowed")
|
||||
}
|
||||
if !floor.Decide(doc, c, addon, "workspace.commit").Allowed {
|
||||
t.Error("a registered operation under the prefix is allowed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEntitled(t *testing.T) {
|
||||
doc := emptySnapshot()
|
||||
c := Context{Identity: ident}
|
||||
if (Floor{}).Entitled(doc, c, addon) {
|
||||
t.Error("nothing granted means not entitled")
|
||||
}
|
||||
if !(Floor{Grants: map[string][]string{addon: {"egress."}}}).Entitled(doc, c, addon) {
|
||||
t.Error("a floor grant entitles")
|
||||
}
|
||||
if (Floor{Grants: map[string][]string{addon: {"egress."}}, Deny: map[string][]string{addon: {"*"}}}).Entitled(doc, c, addon) {
|
||||
t.Error("an add-on-wide deny removes the entitlement")
|
||||
}
|
||||
if !(Floor{Grants: map[string][]string{addon: {"egress."}}, Deny: map[string][]string{addon: {"egress.audit"}}}).Entitled(doc, c, addon) {
|
||||
t.Error("a narrow deny leaves the rest of the prefix")
|
||||
}
|
||||
doc.Grants[addon] = uiEntry(SourceInstall, ident, "egress.check")
|
||||
if !(Floor{}).Entitled(doc, c, addon) {
|
||||
t.Error("a UI entry entitles")
|
||||
}
|
||||
if (Floor{Deny: map[string][]string{"*": {"egress."}}}).Entitled(doc, c, addon) {
|
||||
t.Error("a UI entry that is denied does not entitle")
|
||||
}
|
||||
if (Floor{}).Entitled(doc, Context{Identity: "other"}, addon) {
|
||||
t.Error("an identity mismatch does not entitle")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEffectiveKeepsOrder(t *testing.T) {
|
||||
floor := Floor{Grants: map[string][]string{addon: {"egress."}}}
|
||||
doc := emptySnapshot()
|
||||
got := floor.Effective(doc, Context{Identity: ident}, addon, []string{"workspace.commit", "egress.check"})
|
||||
if len(got) != 2 || got[0].Op != "workspace.commit" || got[0].Allowed || !got[1].Allowed || got[1].Source != SourceDeployment {
|
||||
t.Fatalf("%+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlan(t *testing.T) {
|
||||
registered := map[string]bool{"workspace.materialize": true, "workspace.commit": true, "egress.check": true, "workspace.admin": true}
|
||||
c := Context{Identity: ident, Registered: func(op string) bool { return registered[op] }}
|
||||
floor := Floor{
|
||||
Grants: map[string][]string{addon: {"egress."}},
|
||||
Deny: map[string][]string{addon: {"workspace.admin"}},
|
||||
}
|
||||
doc := emptySnapshot()
|
||||
doc.Deny["*"] = []string{"connector."}
|
||||
p := floor.Plan(doc, c, PlanInput{
|
||||
AddonID: addon,
|
||||
Required: []string{"workspace.materialize", "egress.check"},
|
||||
Optional: []string{"workspace.commit", "workspace.admin", "connector.inbound", "nope.nothing"},
|
||||
// A lying approval: an undeclared name and a denied one.
|
||||
Wanted: []string{"workspace.materialize", "workspace.admin", "undeclared.op", "connector.inbound", "nope.nothing"},
|
||||
})
|
||||
want := Plan{
|
||||
Grant: []string{"workspace.materialize"},
|
||||
Held: []string{"egress.check"},
|
||||
Denied: []string{"workspace.admin", "connector.inbound"},
|
||||
Unsupported: []string{"nope.nothing"},
|
||||
BlockedRequired: nil,
|
||||
MissingRequired: nil,
|
||||
}
|
||||
if !reflect.DeepEqual(p, want) {
|
||||
t.Fatalf("got %+v\nwant %+v", p, want)
|
||||
}
|
||||
|
||||
p = floor.Plan(doc, c, PlanInput{AddonID: addon, Required: []string{"workspace.materialize", "workspace.admin", "nope.nothing"}})
|
||||
if !reflect.DeepEqual(p.BlockedRequired, []string{"workspace.admin"}) ||
|
||||
!reflect.DeepEqual(p.MissingRequired, []string{"workspace.materialize", "nope.nothing"}) || len(p.Grant) != 0 {
|
||||
t.Fatalf("required handling: %+v", p)
|
||||
}
|
||||
// Duplicate declarations count once.
|
||||
p = floor.Plan(doc, c, PlanInput{AddonID: addon, Required: []string{"workspace.commit", "workspace.commit"}, Wanted: []string{"workspace.commit"}})
|
||||
if !reflect.DeepEqual(p.Grant, []string{"workspace.commit"}) {
|
||||
t.Fatalf("%+v", p)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBlanket(t *testing.T) {
|
||||
registered := map[string]bool{"workspace.materialize": true, "workspace.commit": true, "workspace.admin": true}
|
||||
c := Context{Identity: ident, Registered: func(op string) bool { return registered[op] }}
|
||||
in := BlanketInput{AddonID: addon, Official: false,
|
||||
Required: []string{"workspace.materialize"}, Optional: []string{"workspace.commit"}}
|
||||
doc := emptySnapshot()
|
||||
floor := Floor{}
|
||||
|
||||
if r := floor.EvaluateBlanket(doc, c, "", in); r.Applies || len(r.Plan.Grant) != 0 {
|
||||
t.Fatalf("off by default: %+v", r)
|
||||
}
|
||||
doc.Blanket = Blanket{Mode: BlanketOfficial, Revision: 3}
|
||||
if r := floor.EvaluateBlanket(doc, c, "", in); r.Applies {
|
||||
t.Fatalf("official mode needs an official package: %+v", r)
|
||||
}
|
||||
in.Official = true
|
||||
r := floor.EvaluateBlanket(doc, c, "", in)
|
||||
if !r.Applies || r.Revision != 3 || !reflect.DeepEqual(r.Plan.Grant, []string{"workspace.materialize"}) {
|
||||
t.Fatalf("required only: %+v", r)
|
||||
}
|
||||
doc.Blanket.IncludeOptional = true
|
||||
r = floor.EvaluateBlanket(doc, c, "", in)
|
||||
if !reflect.DeepEqual(r.Plan.Grant, []string{"workspace.materialize", "workspace.commit"}) {
|
||||
t.Fatalf("includeOptional: %+v", r)
|
||||
}
|
||||
// any covers a package that is not official.
|
||||
doc.Blanket = Blanket{Mode: BlanketAny, Revision: 4}
|
||||
in.Official = false
|
||||
if r = floor.EvaluateBlanket(doc, c, "", in); !r.Applies || len(r.Plan.Grant) != 1 {
|
||||
t.Fatalf("any: %+v", r)
|
||||
}
|
||||
// A deployment override replaces the stored mode.
|
||||
if r = floor.EvaluateBlanket(doc, c, BlanketOff, in); r.Applies {
|
||||
t.Fatalf("override off: %+v", r)
|
||||
}
|
||||
// A denied required operation blocks and is never granted.
|
||||
in.Required = []string{"workspace.materialize", "workspace.admin"}
|
||||
doc.Deny["*"] = []string{"workspace.admin"}
|
||||
r = floor.EvaluateBlanket(doc, c, "", in)
|
||||
if !reflect.DeepEqual(r.Plan.BlockedRequired, []string{"workspace.admin"}) || reflect.DeepEqual(r.Plan.Grant, []string{"workspace.admin"}) {
|
||||
t.Fatalf("deny beats blanket: %+v", r)
|
||||
}
|
||||
for _, g := range r.Plan.Grant {
|
||||
if g == "workspace.admin" {
|
||||
t.Fatal("blanket wrote a denied operation")
|
||||
}
|
||||
}
|
||||
// Add-ons with no declared request get nothing.
|
||||
if r = floor.EvaluateBlanket(doc, c, "", BlanketInput{AddonID: "slack"}); len(r.Plan.Grant) != 0 {
|
||||
t.Fatalf("%+v", r)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeGrants(t *testing.T) {
|
||||
explicit := uiEntry(SourceInstall, ident, "workspace.commit")
|
||||
merged := MergeGrants(explicit, ident, 2, "op-2", []OpGrant{
|
||||
{Name: "workspace.commit", Source: SourceBlanket, BlanketRevision: 1},
|
||||
{Name: "egress.check", Source: SourceBlanket, BlanketRevision: 1},
|
||||
})
|
||||
if merged.OperationID != "op-2" || merged.Generation != 2 || len(merged.Ops) != 2 || merged.Ops[0].Source != SourceInstall {
|
||||
t.Fatalf("an explicit grant is never relabelled blanket: %+v", merged)
|
||||
}
|
||||
fresh := MergeGrants(explicit, "new-package", 1, "op-3", []OpGrant{{Name: "egress.check", Source: SourceInstall}})
|
||||
if len(fresh.Ops) != 1 || fresh.Identity != "new-package" {
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
package hostgrants
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
"sort"
|
||||
)
|
||||
|
||||
// Reasons for a reconcile removal.
|
||||
const (
|
||||
RemovedNotInstalled = "not_installed"
|
||||
RemovedIdentityMismatch = "identity_mismatch"
|
||||
RemovedOperationIncomplete = "operation_incomplete"
|
||||
)
|
||||
|
||||
// ReconcileInput is what startup knows about the lifecycle state.
|
||||
type ReconcileInput struct {
|
||||
// Installed returns the trusted identity of an installed add-on.
|
||||
Installed func(addonID string) (identity string, ok bool)
|
||||
// OperationCompleted reports whether a lifecycle operation completed in
|
||||
// the journal. Nil treats every operation as completed.
|
||||
OperationCompleted func(operationID string) bool
|
||||
}
|
||||
|
||||
// Removal describes one entry that reconciliation deletes.
|
||||
type Removal struct {
|
||||
AddonID string
|
||||
Reason string
|
||||
Ops []string
|
||||
}
|
||||
|
||||
// PlanReconcile lists the entries to delete: add-ons that are not installed,
|
||||
// entries whose identity differs from the installed package, and pending
|
||||
// entries whose lifecycle operation never completed (a crash between the
|
||||
// grant write and the journal commit). Deny rules, blanket state and hints are
|
||||
// untouched. The result is sorted by add-on ID.
|
||||
func PlanReconcile(snap Snapshot, in ReconcileInput) (Patch, []Removal) {
|
||||
var removals []Removal
|
||||
patch := Patch{Addons: map[string]*AddonGrants{}}
|
||||
for id, entry := range snap.Grants {
|
||||
reason := ""
|
||||
identity, installed := "", false
|
||||
if in.Installed != nil {
|
||||
identity, installed = in.Installed(id)
|
||||
}
|
||||
switch {
|
||||
case !installed:
|
||||
reason = RemovedNotInstalled
|
||||
case identity != entry.Identity:
|
||||
reason = RemovedIdentityMismatch
|
||||
case in.OperationCompleted != nil && !in.OperationCompleted(entry.OperationID):
|
||||
reason = RemovedOperationIncomplete
|
||||
}
|
||||
if reason == "" {
|
||||
continue
|
||||
}
|
||||
ops := make([]string, 0, len(entry.Ops))
|
||||
for _, op := range entry.Ops {
|
||||
ops = append(ops, op.Name)
|
||||
}
|
||||
removals = append(removals, Removal{AddonID: id, Reason: reason, Ops: ops})
|
||||
patch.Addons[id] = nil
|
||||
}
|
||||
sort.Slice(removals, func(i, j int) bool { return removals[i].AddonID < removals[j].AddonID })
|
||||
return patch, removals
|
||||
}
|
||||
|
||||
// Reconcile deletes stale entries in one Update and returns what it removed
|
||||
// so the caller can write a reconcile-removed audit entry for each. It retries
|
||||
// a revision conflict a few times and does nothing when there is nothing to
|
||||
// remove. The operation ID is derived from the revision, so a repeated call
|
||||
// after a lost response is a replay.
|
||||
func (s *Store) Reconcile(ctx context.Context, in ReconcileInput) ([]Removal, error) {
|
||||
for range 3 {
|
||||
snap, err := s.Snapshot(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
patch, removals := PlanReconcile(snap, in)
|
||||
if len(removals) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
_, err = s.Update(ctx, patch, snap.Revision, fmt.Sprintf("reconcile-r%d", snap.Revision))
|
||||
if errors.Is(err, ErrConflict) {
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return slices.Clone(removals), nil
|
||||
}
|
||||
return nil, ErrConflict
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
package hostgrants
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPlanReconcile(t *testing.T) {
|
||||
snap := emptySnapshot()
|
||||
snap.Grants["kept"] = *grant("done", "egress.check")
|
||||
snap.Grants["gone"] = *grant("done", "egress.check", "egress.audit")
|
||||
snap.Grants["swapped"] = *grant("done", "egress.check")
|
||||
snap.Grants["pending"] = *grant("never-completed", "egress.check")
|
||||
snap.Deny["*"] = []string{"connector."}
|
||||
snap.Hints["gone"] = []string{"egress.check"}
|
||||
patch, removals := PlanReconcile(snap, ReconcileInput{
|
||||
Installed: func(id string) (string, bool) {
|
||||
switch id {
|
||||
case "kept", "pending":
|
||||
return "id-1", true
|
||||
case "swapped":
|
||||
return "another-package", true
|
||||
}
|
||||
return "", false
|
||||
},
|
||||
OperationCompleted: func(id string) bool { return id == "done" },
|
||||
})
|
||||
want := []Removal{
|
||||
{AddonID: "gone", Reason: RemovedNotInstalled, Ops: []string{"egress.check", "egress.audit"}},
|
||||
{AddonID: "pending", Reason: RemovedOperationIncomplete, Ops: []string{"egress.check"}},
|
||||
{AddonID: "swapped", Reason: RemovedIdentityMismatch, Ops: []string{"egress.check"}},
|
||||
}
|
||||
if !reflect.DeepEqual(removals, want) {
|
||||
t.Fatalf("got %+v", removals)
|
||||
}
|
||||
if len(patch.Addons) != 3 || patch.Addons["kept"] != nil || patch.Deny != nil || patch.Hints != nil || patch.Blanket != nil {
|
||||
t.Fatalf("the patch must touch only the stale entries: %+v", patch)
|
||||
}
|
||||
}
|
||||
|
||||
// A crash after the grant write and before the journal commit leaves a pending
|
||||
// entry. After a restart, reconciliation removes it, whichever way it died.
|
||||
func TestReconcileAfterACrash(t *testing.T) {
|
||||
s, path := openStore(t)
|
||||
mustUpdate(t, s, Patch{Addons: map[string]*AddonGrants{
|
||||
"half-installed": grant("op-crashed", "workspace.materialize"),
|
||||
"installed": grant("op-done", "egress.check"),
|
||||
"uninstalled": grant("op-old", "egress.check"),
|
||||
}}, 0, "grants-1")
|
||||
// The process dies here: no Close, no cleanup. The lock dies with it.
|
||||
_ = s.Close()
|
||||
|
||||
s2, err := Open(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = s2.Close() }()
|
||||
in := ReconcileInput{
|
||||
Installed: func(id string) (string, bool) { return "id-1", id != "uninstalled" },
|
||||
OperationCompleted: func(id string) bool { return id != "op-crashed" },
|
||||
}
|
||||
removed, err := s2.Reconcile(bg, in)
|
||||
if err != nil || len(removed) != 2 {
|
||||
t.Fatalf("removed %+v, %v", removed, err)
|
||||
}
|
||||
snap, _ := s2.Snapshot(bg)
|
||||
if len(snap.Grants) != 1 || snap.Grants["installed"].OperationID != "op-done" {
|
||||
t.Fatalf("%+v", snap.Grants)
|
||||
}
|
||||
// Nothing left: a second pass writes nothing.
|
||||
rev := snap.Revision
|
||||
removed, err = s2.Reconcile(bg, in)
|
||||
if err != nil || removed != nil {
|
||||
t.Fatalf("%+v, %v", removed, err)
|
||||
}
|
||||
if again, _ := s2.Snapshot(bg); again.Revision != rev {
|
||||
t.Fatal("an empty reconcile must not write")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReconcileReplaysAfterALostResponse(t *testing.T) {
|
||||
s, _ := openStore(t)
|
||||
mustUpdate(t, s, Patch{Addons: map[string]*AddonGrants{"a": grant("op", "egress.check")}}, 0, "g")
|
||||
in := ReconcileInput{Installed: func(string) (string, bool) { return "", false }}
|
||||
snap, _ := s.Snapshot(bg)
|
||||
patch, _ := PlanReconcile(snap, in)
|
||||
if _, err := s.Update(bg, patch, snap.Revision, "reconcile-r1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// The same operation arriving again is a replay, not a second write.
|
||||
r, err := s.Update(bg, patch, snap.Revision, "reconcile-r1")
|
||||
if err != nil || !r.Replayed {
|
||||
t.Fatalf("%+v %v", r, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuditAppendsFsyncedLines(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "host-grants-audit.jsonl")
|
||||
a, err := OpenAudit(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := a.Append(AuditEntry{Event: AuditGrant, AddonID: "sandbox", Ops: []string{"egress.check"}, Source: "install", OperationID: "op-1", RevisionBefore: 1, RevisionAfter: 2, By: "admin"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := a.Append(AuditEntry{Event: AuditBlanketGrant, Blanket: "official@rev3", Time: "2026-11-03T10:00:00Z"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := a.Append(AuditEntry{}); err == nil {
|
||||
t.Error("an entry needs an event")
|
||||
}
|
||||
if err := a.Append(AuditEntry{Event: AuditRefused, Reason: string(make([]byte, maxAuditLine))}); err == nil {
|
||||
t.Error("an oversized entry is refused")
|
||||
}
|
||||
_ = a.Close()
|
||||
if err := a.Append(AuditEntry{Event: AuditGrant}); err == nil {
|
||||
t.Error("a closed audit refuses")
|
||||
}
|
||||
// Reopening appends; it never truncates.
|
||||
a2, err := OpenAudit(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = a2.Append(AuditEntry{Event: AuditRevoke, AddonID: "sandbox"})
|
||||
_ = a2.Close()
|
||||
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = f.Close() }()
|
||||
var events []string
|
||||
sc := bufio.NewScanner(f)
|
||||
for sc.Scan() {
|
||||
var e AuditEntry
|
||||
if err := json.Unmarshal(sc.Bytes(), &e); err != nil || e.Time == "" {
|
||||
t.Fatalf("line %q: %v", sc.Text(), err)
|
||||
}
|
||||
events = append(events, e.Event)
|
||||
}
|
||||
if !reflect.DeepEqual(events, []string{AuditGrant, AuditBlanketGrant, AuditRevoke}) {
|
||||
t.Fatalf("%v", events)
|
||||
}
|
||||
if info, _ := os.Stat(path); info.Mode().Perm() != 0600 {
|
||||
t.Fatalf("mode %v", info.Mode())
|
||||
}
|
||||
var none *Audit
|
||||
if none.Append(AuditEntry{Event: AuditGrant}) != nil || none.Close() != nil {
|
||||
t.Error("a nil audit is a no-op")
|
||||
}
|
||||
link := filepath.Join(t.TempDir(), "link")
|
||||
if err := os.Symlink(path, link); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := OpenAudit(link); err == nil {
|
||||
t.Error("a symlink must be refused")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
package hostgrants
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// Store serializes the owner's grant transactions. The parent directory is
|
||||
// trusted owner storage; callers must not remove the lock file or share the
|
||||
// file over a filesystem without flock and atomic rename and fsync semantics.
|
||||
// The persistence follows the pattern of the host settings store: atomic
|
||||
// rename, directory fsync, compare-and-set on the revision and operation
|
||||
// receipts for idempotent replay.
|
||||
type Store struct {
|
||||
mu sync.Mutex
|
||||
doc document
|
||||
lock *os.File
|
||||
closed bool
|
||||
uncertain bool
|
||||
persist func(document) (renamed bool, err error)
|
||||
}
|
||||
|
||||
// Result is the outcome of Update.
|
||||
type Result struct {
|
||||
Snapshot Snapshot
|
||||
// Replayed is true when the operation ID had already been applied with
|
||||
// the same patch. Snapshot is then the current state, which may be newer
|
||||
// than the state the original call produced.
|
||||
Replayed bool
|
||||
}
|
||||
|
||||
// Open loads the document at path. A missing file is an empty document at
|
||||
// revision 0 and is not created until the first Update. A corrupt or
|
||||
// unreadable file is an error: it is never replaced by a default.
|
||||
func Open(path string) (*Store, error) {
|
||||
path, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = os.MkdirAll(filepath.Dir(path), 0700); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
lock, err := os.OpenFile(path+".lock", os.O_CREATE|os.O_RDWR|syscall.O_NOFOLLOW, 0600) // #nosec G304 -- caller supplies a fixed file in the guarded owner root, never a request path; reject symlinks.
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s := &Store{lock: lock, persist: func(d document) (bool, error) { return persistFile(path, d) }}
|
||||
fail := func(e error) (*Store, error) { _ = s.Close(); return nil, fmt.Errorf("%w: %w", ErrUnavailable, e) }
|
||||
if err = lock.Chmod(0600); err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
if err = syscall.Flock(int(lock.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
f, err := os.OpenFile(path, os.O_RDONLY|syscall.O_NOFOLLOW, 0) // #nosec G304 -- same trusted owner-root file; NOFOLLOW plus mode/type checks below prevent file substitution.
|
||||
if os.IsNotExist(err) {
|
||||
s.doc = document{Snapshot: emptySnapshot(), Receipts: map[string]receipt{}}
|
||||
return s, nil
|
||||
}
|
||||
if err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
info, statErr := f.Stat()
|
||||
if statErr != nil {
|
||||
_ = f.Close()
|
||||
return fail(statErr)
|
||||
}
|
||||
if !info.Mode().IsRegular() || info.Mode().Perm()&0077 != 0 || info.Size() > maxDocumentBytes {
|
||||
_ = f.Close()
|
||||
return fail(ErrInvalid)
|
||||
}
|
||||
b, readErr := io.ReadAll(io.LimitReader(f, maxDocumentBytes+1))
|
||||
closeErr := f.Close()
|
||||
if err = errors.Join(readErr, closeErr); err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
var d document
|
||||
if len(b) > maxDocumentBytes || strictJSON(b, &d) != nil {
|
||||
return fail(ErrInvalid)
|
||||
}
|
||||
if d.Receipts == nil {
|
||||
d.Receipts = map[string]receipt{}
|
||||
}
|
||||
if d.Grants == nil {
|
||||
d.Grants = map[string]AddonGrants{}
|
||||
}
|
||||
if d.Deny == nil {
|
||||
d.Deny = map[string][]string{}
|
||||
}
|
||||
if d.Hints == nil {
|
||||
d.Hints = map[string][]string{}
|
||||
}
|
||||
if err = validateDocument(d); err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
s.doc = d
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func strictJSON(b []byte, v any) error {
|
||||
d := json.NewDecoder(bytes.NewReader(b))
|
||||
d.DisallowUnknownFields()
|
||||
if err := d.Decode(v); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := d.Token(); err != io.EOF {
|
||||
return ErrInvalid
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) usable() error {
|
||||
switch {
|
||||
case s.closed:
|
||||
return ErrUnavailable
|
||||
case s.uncertain:
|
||||
return ErrUncertain
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Snapshot returns a copy of the current document.
|
||||
func (s *Store) Snapshot(ctx context.Context) (Snapshot, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if err := ctx.Err(); err != nil {
|
||||
return Snapshot{}, err
|
||||
}
|
||||
if err := s.usable(); err != nil {
|
||||
return Snapshot{}, err
|
||||
}
|
||||
return s.doc.Snapshot.Clone(), nil
|
||||
}
|
||||
|
||||
// Update applies patch if the document is at expectedRevision. A repeated
|
||||
// operationID with the same patch returns Replayed without a write, checked
|
||||
// before the revision; the same ID with another patch is ErrOperationMismatch.
|
||||
// Once persistence begins it completes even if ctx is cancelled. After
|
||||
// ErrUncertain the store refuses all calls until it is reopened; the caller
|
||||
// then replays the same operation ID and patch.
|
||||
func (s *Store) Update(ctx context.Context, patch Patch, expectedRevision uint64, operationID string) (Result, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if err := ctx.Err(); err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
if err := s.usable(); err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
if !validOperationID(operationID) {
|
||||
return Result{}, invalid("operation ID")
|
||||
}
|
||||
body, err := json.Marshal(patch)
|
||||
if err != nil {
|
||||
return Result{}, invalid("patch")
|
||||
}
|
||||
sum := sha256.Sum256(body)
|
||||
hash := hex.EncodeToString(sum[:])
|
||||
if r, ok := s.doc.Receipts[operationID]; ok {
|
||||
if r.Body != hash {
|
||||
return Result{}, ErrOperationMismatch
|
||||
}
|
||||
return Result{Snapshot: s.doc.Snapshot.Clone(), Replayed: true}, nil
|
||||
}
|
||||
if expectedRevision != s.doc.Revision {
|
||||
return Result{}, ErrConflict
|
||||
}
|
||||
next, err := apply(s.doc.Snapshot, patch)
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
next.Revision = s.doc.Revision + 1
|
||||
nd := document{Snapshot: next, Receipts: make(map[string]receipt, len(s.doc.Receipts)+1)}
|
||||
for id, r := range s.doc.Receipts {
|
||||
nd.Receipts[id] = r
|
||||
}
|
||||
nd.Receipts[operationID] = receipt{Body: hash, Revision: next.Revision}
|
||||
for len(nd.Receipts) > MaxReceipts {
|
||||
var oldestID string
|
||||
var oldest uint64
|
||||
for id, r := range nd.Receipts {
|
||||
if oldestID == "" || r.Revision < oldest {
|
||||
oldestID, oldest = id, r.Revision
|
||||
}
|
||||
}
|
||||
delete(nd.Receipts, oldestID)
|
||||
}
|
||||
renamed, err := s.persist(nd)
|
||||
if err != nil {
|
||||
if renamed {
|
||||
s.uncertain = true
|
||||
return Result{}, errors.Join(ErrUncertain, err)
|
||||
}
|
||||
return Result{}, err
|
||||
}
|
||||
s.doc = nd
|
||||
return Result{Snapshot: next.Clone()}, nil
|
||||
}
|
||||
|
||||
// Close releases the lock. The store refuses all calls afterwards.
|
||||
func (s *Store) Close() error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.closed = true
|
||||
if s.lock == nil {
|
||||
return nil
|
||||
}
|
||||
err := errors.Join(syscall.Flock(int(s.lock.Fd()), syscall.LOCK_UN), s.lock.Close())
|
||||
s.lock = nil
|
||||
return err
|
||||
}
|
||||
|
||||
// The boolean separates a safely rejected write from an ambiguous commit.
|
||||
func persistFile(path string, d document) (bool, error) {
|
||||
b, err := json.Marshal(d)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if len(b) > maxDocumentBytes {
|
||||
return false, invalid("document too large")
|
||||
}
|
||||
dir, err := os.Open(filepath.Dir(path))
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer func() { _ = dir.Close() }() // The explicit Sync below determines commit durability.
|
||||
f, err := os.CreateTemp(filepath.Dir(path), ".host-grants-*")
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer func() { _ = os.Remove(f.Name()) }() // Best-effort removal; a successful rename already removed this name.
|
||||
if _, err = f.Write(b); err == nil {
|
||||
err = f.Sync()
|
||||
}
|
||||
err = errors.Join(err, f.Close())
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if err = os.Rename(f.Name(), path); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if err = dir.Sync(); err != nil {
|
||||
return true, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
@@ -0,0 +1,467 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
package hostgrants
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
var bg = context.Background()
|
||||
|
||||
func grant(opID string, ops ...string) *AddonGrants {
|
||||
g := &AddonGrants{Identity: "id-1", Generation: 1, OperationID: opID}
|
||||
for _, o := range ops {
|
||||
g.Ops = append(g.Ops, OpGrant{Name: o, Source: SourceInstall, At: "2026-11-03T10:00:00Z", By: "admin", Version: "1.0.0"})
|
||||
}
|
||||
return g
|
||||
}
|
||||
|
||||
func openStore(t *testing.T) (*Store, string) {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "policy", "host-grants.json")
|
||||
s, err := Open(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = s.Close() })
|
||||
return s, path
|
||||
}
|
||||
|
||||
func mustUpdate(t *testing.T, s *Store, p Patch, rev uint64, id string) Snapshot {
|
||||
t.Helper()
|
||||
r, err := s.Update(bg, p, rev, id)
|
||||
if err != nil {
|
||||
t.Fatalf("update %s: %v", id, err)
|
||||
}
|
||||
return r.Snapshot
|
||||
}
|
||||
|
||||
func TestEmptyStoreDoesNotCreateTheDocument(t *testing.T) {
|
||||
s, path := openStore(t)
|
||||
snap, err := s.Snapshot(bg)
|
||||
if err != nil || snap.Revision != 0 || snap.Blanket.Mode != BlanketOff || len(snap.Grants) != 0 {
|
||||
t.Fatalf("unexpected empty snapshot %+v, %v", snap, err)
|
||||
}
|
||||
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
||||
t.Fatalf("document must not exist before the first write: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdatePersistsAndReopens(t *testing.T) {
|
||||
s, path := openStore(t)
|
||||
snap := mustUpdate(t, s, Patch{Addons: map[string]*AddonGrants{"sandbox": grant("op-1", "workspace.commit", "workspace.materialize")}}, 0, "w1")
|
||||
if snap.Revision != 1 || snap.Grants["sandbox"].Ops[0].Name != "workspace.commit" {
|
||||
t.Fatalf("ops must be sorted and revision 1: %+v", snap)
|
||||
}
|
||||
info, err := os.Stat(path)
|
||||
if err != nil || info.Mode().Perm() != 0600 {
|
||||
t.Fatalf("document mode: %v %v", info, err)
|
||||
}
|
||||
if err := s.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s2, err := Open(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = s2.Close() }()
|
||||
got, _ := s2.Snapshot(bg)
|
||||
if got.Revision != 1 || len(got.Grants["sandbox"].Ops) != 2 {
|
||||
t.Fatalf("reopened %+v", got)
|
||||
}
|
||||
// The replay survives a reopen.
|
||||
r, err := s2.Update(bg, Patch{Addons: map[string]*AddonGrants{"sandbox": grant("op-1", "workspace.commit", "workspace.materialize")}}, 0, "w1")
|
||||
if err != nil || !r.Replayed || r.Snapshot.Revision != 1 {
|
||||
t.Fatalf("replay after reopen: %+v %v", r, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRevisionConflictReplayAndBodyMismatch(t *testing.T) {
|
||||
s, _ := openStore(t)
|
||||
p := Patch{Addons: map[string]*AddonGrants{"a": grant("op", "egress.check")}}
|
||||
mustUpdate(t, s, p, 0, "w1")
|
||||
if _, err := s.Update(bg, p, 0, "w2"); !errors.Is(err, ErrConflict) {
|
||||
t.Fatalf("stale revision must conflict: %v", err)
|
||||
}
|
||||
r, err := s.Update(bg, p, 0, "w1") // replay is checked before the revision
|
||||
if err != nil || !r.Replayed {
|
||||
t.Fatalf("replay: %+v %v", r, err)
|
||||
}
|
||||
other := Patch{Addons: map[string]*AddonGrants{"a": grant("op", "egress.audit")}}
|
||||
if _, err := s.Update(bg, other, 1, "w1"); !errors.Is(err, ErrOperationMismatch) {
|
||||
t.Fatalf("changed body must mismatch: %v", err)
|
||||
}
|
||||
snap, _ := s.Snapshot(bg)
|
||||
if snap.Revision != 1 {
|
||||
t.Fatalf("rejected writes must not bump the revision: %d", snap.Revision)
|
||||
}
|
||||
for _, id := range []string{"", " x", strings.Repeat("a", 129), "bad\nid"} {
|
||||
if _, err := s.Update(bg, Patch{}, 1, id); !errors.Is(err, ErrInvalid) {
|
||||
t.Fatalf("operation ID %q: %v", id, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLimitsAndStrictValues(t *testing.T) {
|
||||
s, _ := openStore(t)
|
||||
many := map[string]*AddonGrants{}
|
||||
for i := 0; i <= MaxAddons; i++ {
|
||||
many[fmt.Sprintf("a-%d", i)] = grant("op", "egress.check")
|
||||
}
|
||||
ops := []string{}
|
||||
for i := 0; i <= MaxOpsPerAddon; i++ {
|
||||
ops = append(ops, fmt.Sprintf("x.%c%c", 'a'+i/26, 'a'+i%26))
|
||||
}
|
||||
deny := func(m map[string][]string) Patch { return Patch{Deny: &m} }
|
||||
dupOp := grant("op", "egress.check")
|
||||
dupOp.Ops = append(dupOp.Ops, dupOp.Ops[0])
|
||||
noIdentity := grant("op", "egress.check")
|
||||
noIdentity.Identity = ""
|
||||
noOps := grant("op")
|
||||
prefixOp := grant("op", "egress.")
|
||||
badSource := grant("op", "egress.check")
|
||||
badSource.Ops[0].Source = "api"
|
||||
blanketNoRev := grant("op", "egress.check")
|
||||
blanketNoRev.Ops[0].Source = SourceBlanket
|
||||
installWithRev := grant("op", "egress.check")
|
||||
installWithRev.Ops[0].BlanketRevision = 1
|
||||
badTime := grant("op", "egress.check")
|
||||
badTime.Ops[0].At = "yesterday"
|
||||
manyDeny := map[string][]string{}
|
||||
for i := 0; i <= MaxDenyKeys; i++ {
|
||||
manyDeny[fmt.Sprintf("a-%d", i)] = []string{"x."}
|
||||
}
|
||||
cases := map[string]Patch{
|
||||
"129 add-ons": {Addons: many},
|
||||
"33 operations": {Addons: map[string]*AddonGrants{"a": grant("op", ops...)}},
|
||||
"bad add-on ID": {Addons: map[string]*AddonGrants{"A": grant("op", "egress.check")}},
|
||||
"star as add-on ID": {Addons: map[string]*AddonGrants{"*": grant("op", "egress.check")}},
|
||||
"duplicate operation": {Addons: map[string]*AddonGrants{"a": dupOp}},
|
||||
"empty identity": {Addons: map[string]*AddonGrants{"a": noIdentity}},
|
||||
"entry without ops": {Addons: map[string]*AddonGrants{"a": noOps}},
|
||||
"prefix as a grant": {Addons: map[string]*AddonGrants{"a": prefixOp}},
|
||||
"unknown source": {Addons: map[string]*AddonGrants{"a": badSource}},
|
||||
"blanket without rev": {Addons: map[string]*AddonGrants{"a": blanketNoRev}},
|
||||
"revision without src": {Addons: map[string]*AddonGrants{"a": installWithRev}},
|
||||
"bad time": {Addons: map[string]*AddonGrants{"a": badTime}},
|
||||
"blanket mode": {Blanket: &Blanket{Mode: "sometimes"}},
|
||||
"129 deny keys": deny(manyDeny),
|
||||
"duplicate deny entry": deny(map[string][]string{"a": {"x.", "x."}}),
|
||||
"star beside entry": deny(map[string][]string{"a": {"*", "x."}}),
|
||||
"bad deny entry": deny(map[string][]string{"a": {"X."}}),
|
||||
"bad deny key": deny(map[string][]string{"A": {"x."}}),
|
||||
"bad hint": {Hints: map[string][]string{"a": {"x."}}},
|
||||
}
|
||||
for name, p := range cases {
|
||||
if _, err := s.Update(bg, p, 0, "w-"+strings.ReplaceAll(name, " ", "-")); !errors.Is(err, ErrInvalid) {
|
||||
t.Errorf("%s: want ErrInvalid, got %v", name, err)
|
||||
}
|
||||
}
|
||||
snap, _ := s.Snapshot(bg)
|
||||
if snap.Revision != 0 {
|
||||
t.Fatalf("invalid patches must not change the document: %+v", snap)
|
||||
}
|
||||
// Boundaries that are valid.
|
||||
max := map[string]*AddonGrants{}
|
||||
for i := 0; i < MaxAddons; i++ {
|
||||
max[fmt.Sprintf("a-%d", i)] = grant("op", "egress.check")
|
||||
}
|
||||
mustUpdate(t, s, Patch{Addons: max}, 0, "max")
|
||||
mustUpdate(t, s, Patch{Addons: map[string]*AddonGrants{"a-0": grant("op", ops[:MaxOpsPerAddon]...)}}, 1, "max-ops")
|
||||
}
|
||||
|
||||
func TestBlanketRevisionAndRevoke(t *testing.T) {
|
||||
s, _ := openStore(t)
|
||||
snap := mustUpdate(t, s, Patch{Blanket: &Blanket{Mode: BlanketOfficial, ChangedAt: "2026-11-03T10:00:00Z", ChangedBy: "admin", Revision: 99}}, 0, "b1")
|
||||
if snap.Blanket.Revision != 1 {
|
||||
t.Fatalf("store assigns the blanket revision: %+v", snap.Blanket)
|
||||
}
|
||||
g := grant("op", "egress.check", "egress.audit")
|
||||
g.Ops[1].Source, g.Ops[1].BlanketRevision = SourceBlanket, 1
|
||||
only := grant("op2", "workspace.admin")
|
||||
only.Ops[0].Source, only.Ops[0].BlanketRevision = SourceBlanket, 1
|
||||
snap = mustUpdate(t, s, Patch{Addons: map[string]*AddonGrants{"a": g, "b": only}}, 1, "g1")
|
||||
// Same mode and option keep the revision; a change bumps it.
|
||||
snap = mustUpdate(t, s, Patch{Blanket: &Blanket{Mode: BlanketOfficial}}, snap.Revision, "b2")
|
||||
if snap.Blanket.Revision != 1 {
|
||||
t.Fatalf("unchanged switch keeps its revision: %d", snap.Blanket.Revision)
|
||||
}
|
||||
snap = mustUpdate(t, s, Patch{Blanket: &Blanket{Mode: BlanketOff}}, snap.Revision, "b3")
|
||||
if snap.Blanket.Revision != 2 || len(snap.Grants["a"].Ops) != 2 {
|
||||
t.Fatalf("turning off keeps the grants: %+v", snap)
|
||||
}
|
||||
snap = mustUpdate(t, s, Patch{RevokeBlanket: true}, snap.Revision, "r1")
|
||||
if _, ok := snap.Grants["b"]; ok || len(snap.Grants["a"].Ops) != 1 || snap.Grants["a"].Ops[0].Name != "egress.check" {
|
||||
t.Fatalf("revoke removes only blanket ops and empty entries: %+v", snap.Grants)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeepAsOwnSurvivesBlanketRevoke(t *testing.T) {
|
||||
g := *grant("op", "egress.check")
|
||||
g.Ops[0].Source, g.Ops[0].BlanketRevision = SourceBlanket, 3
|
||||
kept := KeepAsOwn(g, "2026-11-04T10:00:00Z", "admin")
|
||||
if kept.Ops[0].Source != SourceEdit || kept.Ops[0].BlanketRevision != 0 || g.Ops[0].Source != SourceBlanket {
|
||||
t.Fatalf("KeepAsOwn must relabel a copy: %+v / %+v", kept, g)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHintsAndDenyReplace(t *testing.T) {
|
||||
s, _ := openStore(t)
|
||||
deny := map[string][]string{"*": {"egress."}, "web": {"workspace.admin"}}
|
||||
snap := mustUpdate(t, s, Patch{Deny: &deny, Hints: map[string][]string{"a": {"workspace.commit", "workspace.admin"}}}, 0, "d1")
|
||||
if len(snap.Deny) != 2 || snap.Hints["a"][0] != "workspace.admin" {
|
||||
t.Fatalf("%+v", snap)
|
||||
}
|
||||
empty := map[string][]string{}
|
||||
snap = mustUpdate(t, s, Patch{Deny: &empty, Hints: map[string][]string{"a": nil}}, 1, "d2")
|
||||
if len(snap.Deny) != 0 || len(snap.Hints) != 0 {
|
||||
t.Fatalf("a pointer to an empty map clears; an empty hint deletes: %+v", snap)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPersistFailureLeavesTheOldDocument(t *testing.T) {
|
||||
s, path := openStore(t)
|
||||
mustUpdate(t, s, Patch{Addons: map[string]*AddonGrants{"a": grant("op", "egress.check")}}, 0, "w1")
|
||||
real := s.persist
|
||||
s.persist = func(document) (bool, error) { return false, errors.New("disk full") }
|
||||
if _, err := s.Update(bg, Patch{Addons: map[string]*AddonGrants{"a": nil}}, 1, "w2"); err == nil {
|
||||
t.Fatal("expected the write to fail")
|
||||
}
|
||||
snap, err := s.Snapshot(bg)
|
||||
if err != nil || snap.Revision != 1 || len(snap.Grants["a"].Ops) != 1 {
|
||||
t.Fatalf("a failed write must leave memory unchanged: %+v %v", snap, err)
|
||||
}
|
||||
s.persist = real
|
||||
// The failed operation ID was never recorded, so it can be used again.
|
||||
mustUpdate(t, s, Patch{Addons: map[string]*AddonGrants{"a": nil}}, 1, "w2")
|
||||
_ = s.Close()
|
||||
s2, err := Open(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = s2.Close() }()
|
||||
if got, _ := s2.Snapshot(bg); got.Revision != 2 || len(got.Grants) != 0 {
|
||||
t.Fatalf("%+v", got)
|
||||
}
|
||||
matches, _ := filepath.Glob(filepath.Join(filepath.Dir(path), ".host-grants-*"))
|
||||
if len(matches) != 0 {
|
||||
t.Fatalf("temporary files left behind: %v", matches)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAmbiguousCommitMakesTheStoreRefuseUntilReopened(t *testing.T) {
|
||||
s, path := openStore(t)
|
||||
real := s.persist
|
||||
s.persist = func(d document) (bool, error) {
|
||||
renamed, _ := real(d)
|
||||
return renamed, errors.New("directory sync failed")
|
||||
}
|
||||
p := Patch{Addons: map[string]*AddonGrants{"a": grant("op", "egress.check")}}
|
||||
if _, err := s.Update(bg, p, 0, "w1"); !errors.Is(err, ErrUncertain) {
|
||||
t.Fatalf("want ErrUncertain, got %v", err)
|
||||
}
|
||||
if _, err := s.Snapshot(bg); !errors.Is(err, ErrUncertain) {
|
||||
t.Fatalf("snapshot must refuse: %v", err)
|
||||
}
|
||||
_ = s.Close()
|
||||
s2, err := Open(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = s2.Close() }()
|
||||
r, err := s2.Update(bg, p, 0, "w1")
|
||||
if err != nil || !r.Replayed || r.Snapshot.Revision != 1 {
|
||||
t.Fatalf("the same operation reconciles as a replay: %+v %v", r, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenRejectsBadFiles(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
write := func(name, body string, mode os.FileMode) string {
|
||||
p := filepath.Join(dir, name)
|
||||
if err := os.WriteFile(p, []byte(body), mode); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return p
|
||||
}
|
||||
good := `{"schemaVersion":1,"revision":1,"blanket":{"mode":"off","includeOptional":false,"revision":0},"grants":{},"deny":{},"hints":{},"receipts":{}}`
|
||||
if s, err := Open(write("good.json", good, 0600)); err != nil {
|
||||
t.Fatalf("control: %v", err)
|
||||
} else {
|
||||
_ = s.Close()
|
||||
}
|
||||
bad := map[string]struct {
|
||||
body string
|
||||
mode os.FileMode
|
||||
}{
|
||||
"corrupt": {`{`, 0600},
|
||||
"group-readable": {good, 0640},
|
||||
"unknown field": {strings.Replace(good, `"hints"`, `"extra":1,"hints"`, 1), 0600},
|
||||
"revision 0": {strings.Replace(good, `"revision":1,"blanket"`, `"revision":0,"blanket"`, 1), 0600},
|
||||
"version 2": {strings.Replace(good, `"schemaVersion":1`, `"schemaVersion":2`, 1), 0600},
|
||||
"trailing": {good + `{}`, 0600},
|
||||
"bad receipt": {strings.Replace(good, `"receipts":{}`, `"receipts":{"x":{"body":"zz","revision":1}}`, 1), 0600},
|
||||
}
|
||||
for name, c := range bad {
|
||||
if s, err := Open(write(strings.ReplaceAll(name, " ", "-")+".json", c.body, c.mode)); err == nil {
|
||||
_ = s.Close()
|
||||
t.Errorf("%s: expected an error", name)
|
||||
} else if !errors.Is(err, ErrUnavailable) {
|
||||
t.Errorf("%s: want ErrUnavailable, got %v", name, err)
|
||||
}
|
||||
}
|
||||
// A symlink to the document is refused.
|
||||
link := filepath.Join(dir, "link.json")
|
||||
if err := os.Symlink(filepath.Join(dir, "good.json"), link); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s, err := Open(link); err == nil {
|
||||
_ = s.Close()
|
||||
t.Error("symlink must be refused")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecondOpenIsRefusedWhileLocked(t *testing.T) {
|
||||
_, path := openStore(t)
|
||||
if s2, err := Open(path); err == nil {
|
||||
_ = s2.Close()
|
||||
t.Fatal("a second owner must not open the same document")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReceiptsAreBounded(t *testing.T) {
|
||||
s, _ := openStore(t)
|
||||
var rev uint64
|
||||
for i := 0; i < MaxReceipts+5; i++ {
|
||||
rev = mustUpdate(t, s, Patch{Hints: map[string][]string{"a": {"egress.check"}}}, rev, fmt.Sprintf("w%d", i)).Revision
|
||||
}
|
||||
if len(s.doc.Receipts) != MaxReceipts {
|
||||
t.Fatalf("receipts = %d", len(s.doc.Receipts))
|
||||
}
|
||||
if _, ok := s.doc.Receipts["w0"]; ok {
|
||||
t.Fatal("the oldest receipt must be evicted first")
|
||||
}
|
||||
if _, ok := s.doc.Receipts[fmt.Sprintf("w%d", MaxReceipts+4)]; !ok {
|
||||
t.Fatal("the newest receipt must stay")
|
||||
}
|
||||
}
|
||||
|
||||
// Many writers race on one document. Each retries on a revision conflict with
|
||||
// the same operation ID, as a real caller does. Run with -race.
|
||||
func TestConcurrentWritersAreSerialised(t *testing.T) {
|
||||
s, path := openStore(t)
|
||||
const writers = 24
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < writers; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
id := fmt.Sprintf("a-%d", i)
|
||||
opID := fmt.Sprintf("op-%d", i)
|
||||
for {
|
||||
snap, err := s.Snapshot(bg)
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
return
|
||||
}
|
||||
_, err = s.Update(bg, Patch{Addons: map[string]*AddonGrants{id: grant(opID, "egress.check")}}, snap.Revision, opID)
|
||||
if errors.Is(err, ErrConflict) {
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
return
|
||||
}
|
||||
}()
|
||||
}
|
||||
// Concurrent readers must always see a consistent, validated document.
|
||||
stop := make(chan struct{})
|
||||
var readers sync.WaitGroup
|
||||
for i := 0; i < 4; i++ {
|
||||
readers.Add(1)
|
||||
go func() {
|
||||
defer readers.Done()
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
default:
|
||||
}
|
||||
snap, err := s.Snapshot(bg)
|
||||
if err != nil || validate(snap) != nil {
|
||||
t.Errorf("inconsistent snapshot: %v", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
close(stop)
|
||||
readers.Wait()
|
||||
snap, _ := s.Snapshot(bg)
|
||||
if snap.Revision != writers || len(snap.Grants) != writers {
|
||||
t.Fatalf("revision %d with %d entries, want %d", snap.Revision, len(snap.Grants), writers)
|
||||
}
|
||||
_ = s.Close()
|
||||
s2, err := Open(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = s2.Close() }()
|
||||
if got, _ := s2.Snapshot(bg); got.Revision != writers {
|
||||
t.Fatalf("persisted revision %d", got.Revision)
|
||||
}
|
||||
}
|
||||
|
||||
// The same operation sent from many goroutines (a retrying client) writes once.
|
||||
func TestConcurrentReplayWritesOnce(t *testing.T) {
|
||||
s, _ := openStore(t)
|
||||
p := Patch{Addons: map[string]*AddonGrants{"a": grant("op", "egress.check")}}
|
||||
var wg sync.WaitGroup
|
||||
var mu sync.Mutex
|
||||
replays := 0
|
||||
for i := 0; i < 16; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
r, err := s.Update(bg, p, 0, "same")
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
return
|
||||
}
|
||||
if r.Replayed {
|
||||
mu.Lock()
|
||||
replays++
|
||||
mu.Unlock()
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
snap, _ := s.Snapshot(bg)
|
||||
if snap.Revision != 1 || replays != 15 {
|
||||
t.Fatalf("revision %d, replays %d", snap.Revision, replays)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCancelledContextDoesNotWrite(t *testing.T) {
|
||||
s, _ := openStore(t)
|
||||
ctx, cancel := context.WithCancel(bg)
|
||||
cancel()
|
||||
if _, err := s.Update(ctx, Patch{}, 0, "w"); !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("%v", err)
|
||||
}
|
||||
if _, err := s.Snapshot(ctx); !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("%v", err)
|
||||
}
|
||||
_ = s.Close()
|
||||
if _, err := s.Snapshot(bg); !errors.Is(err, ErrUnavailable) {
|
||||
t.Fatalf("closed store: %v", err)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user