diff --git a/core/services/addons/hostgrants/audit.go b/core/services/addons/hostgrants/audit.go new file mode 100644 index 0000000000..6f4a80a05d --- /dev/null +++ b/core/services/addons/hostgrants/audit.go @@ -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) +} diff --git a/core/services/addons/hostgrants/blanket.go b/core/services/addons/hostgrants/blanket.go new file mode 100644 index 0000000000..e1aef1b79e --- /dev/null +++ b/core/services/addons/hostgrants/blanket.go @@ -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 +} diff --git a/core/services/addons/hostgrants/document.go b/core/services/addons/hostgrants/document.go new file mode 100644 index 0000000000..12fa369b2b --- /dev/null +++ b/core/services/addons/hostgrants/document.go @@ -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 +} diff --git a/core/services/addons/hostgrants/policy.go b/core/services/addons/hostgrants/policy.go new file mode 100644 index 0000000000..9f3df3bff8 --- /dev/null +++ b/core/services/addons/hostgrants/policy.go @@ -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 +} diff --git a/core/services/addons/hostgrants/policy_test.go b/core/services/addons/hostgrants/policy_test.go new file mode 100644 index 0000000000..b17cb461ab --- /dev/null +++ b/core/services/addons/hostgrants/policy_test.go @@ -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)) + } + } +} diff --git a/core/services/addons/hostgrants/reconcile.go b/core/services/addons/hostgrants/reconcile.go new file mode 100644 index 0000000000..ad65511160 --- /dev/null +++ b/core/services/addons/hostgrants/reconcile.go @@ -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 +} diff --git a/core/services/addons/hostgrants/reconcile_test.go b/core/services/addons/hostgrants/reconcile_test.go new file mode 100644 index 0000000000..d68634ee67 --- /dev/null +++ b/core/services/addons/hostgrants/reconcile_test.go @@ -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") + } +} diff --git a/core/services/addons/hostgrants/store.go b/core/services/addons/hostgrants/store.go new file mode 100644 index 0000000000..99fda9d956 --- /dev/null +++ b/core/services/addons/hostgrants/store.go @@ -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 +} diff --git a/core/services/addons/hostgrants/store_test.go b/core/services/addons/hostgrants/store_test.go new file mode 100644 index 0000000000..46ef0a2610 --- /dev/null +++ b/core/services/addons/hostgrants/store_test.go @@ -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) + } +}