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:
Ettore Di Giacinto committed 2026-11-03 21:22:56 +00:00
1 parent 082fd7ee7d
commit 4a1210bfb3
9 files changed
+2165

No files matched your search

+125
View File
@@ -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)
}
+102
View File
@@ -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
}
+391
View File
@@ -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
}
+243
View File
@@ -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")
}
}
+257
View File
@@ -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)
}
}