bless

package module
v0.1.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 3, 2026 License: Apache-2.0 Imports: 10 Imported by: 0

README

bless

The elder.build minimalist authorization library.

People are blessed before they can participate in a project, or cursed to block them. bless records who is trusted. What that permits is up to you.

Inspired by github.com/mitchellh/vouch.

The trust file

# Maintainers
# Elevated trust
+alice@example.com
+bob@proton.me

# Contributors
# Can participate
charlie@gmail.com
dave@fastmail.com referred by alice

# Cursed
# Blocked, can't participate
-spammer@throwaway.com AI slop

# Qualified by namespace
email:erin@example.com
ssh:SHA256:abc
github:frank

# Inherit trust from other projects
@~alice/other-project

One identity per line. A leading + is a maintainer, a leading - is a curse, anything else is a contributor. Text after the identity is a free-text note. An @ref line inherits another project's list. Comments go on their own line.

An identity may name its namespace as kind:value. Any prefix is a kind, so a new platform needs no release. Written bare it is in its own namespace, so alice@example.com does not match a lookup for email:alice@example.com.

See BLESS.example for a file covering every part of the format.

Cookbook

Install
go get git.elder.build/bless
Check whether someone is trusted
f, err := bless.Parse(data)
if err != nil {
    return err
}

switch f.Check(bless.Email("alice@example.com")) {
case bless.RoleMaintainer:
    // elevated trust
case bless.RoleContributor:
    // ordinary participant
case bless.RoleCursed:
    // explicitly blocked
case bless.RoleUnknown:
    // not listed
}

Or, when you only need to know whether they are trusted at all:

if !f.Check(id).IsAllowed() {
    return errDenied
}

When you need to say why, ask for the entry that decided it. An identity listed more than once is settled by the strongest claim, and Lookup returns that one entry, so its role and reason always describe the answer you got:

if e, ok := f.Lookup(id); ok && e.Role == bless.RoleCursed {
    return fmt.Errorf("blocked: %s", e.Reason)
}
Add and remove people
f.Bless(bless.Email("new@example.com"), "good first patch", false)
f.Bless(bless.Email("lead@example.com"), "", true) // true means maintainer
f.Curse(bless.Bare("spammer@example.com"), "spam")
f.Remove(bless.Email("left@example.com"))

f.AddInherit("~org/shared-trust")
f.RemoveInherit("~alice/other-project")

Bless replaces every matching entry. The boolean selects the exact role, so false demotes an existing maintainer. The supplied reason replaces the old one.

New entries are appended. Nothing is sorted: a BLESS file is a document, and moving an entry would slide it under whatever comment happens to sit above its new position.

A reason is free text, but it is one line. An identity is one word. Encode refuses an entry it could not read back, so a reason holding a newline is an error rather than an extra entry in the file.

Read what is in a file
for _, e := range f.Entries() {
    fmt.Println(e.Role, e.Identity, e.Reason)
}

Entries and Inherits return copies in document order. Changing what you get back does not change the file, so the entries and the comments describing them cannot drift apart. Edit through the methods above.

Save the file
out, err := f.Encode()
if err != nil {
    return err
}
os.WriteFile(bless.BlessFile, out, 0o644)

Comments, blank lines and identity kinds survive the round trip. Encode normalizes line endings to LF and terminates every non-empty file. Whatever it returns, Parse reads back identically.

Look across projects

Tell bless how to fetch a file for a ref:

type myLoader struct{ /* your storage */ }

func (l myLoader) LoadTrust(ref string) (*bless.File, error) {
    if err := l.validateRef(ref); err != nil {
        return nil, err
    }

    data, err := l.fetch(ref)
    if errors.Is(err, fs.ErrNotExist) {
        return nil, bless.ErrNotFound // an ordinary no-match, not a failure
    }
    if err != nil {
        return nil, err
    }
    return bless.Parse(data)
}

Refs come from BLESS files and are untrusted. A loader must reject refs outside the paths, URL schemes, hosts or namespaces it permits. Do this before using a ref in a filesystem or network operation.

Then resolve through @ref inheritance:

r := bless.NewResolver(myLoader{}, bless.WithMaxDepth(3), bless.WithMaxRefs(100))

result, err := r.Resolve("~alice/repo", bless.Email("someone@example.com"))
if err != nil {
    // The search did not finish, so result may be wrong. Fail closed.
}

result.Role   // the resolved role
result.Source // which file matched
result.Entry  // the entry that decided it, with its reason

A resolver is fixed once built, so one can be shared across goroutines. It serializes loader calls. Files returned by the loader must not be changed while a resolution may be reading them.

Decide what a role may do

bless does not model actions. Pushing, releasing and moderating are your vocabulary, not a trust list's:

switch result.Role {
case bless.RoleMaintainer:
    allowPush()
    allowRelease()
case bless.RoleContributor:
    allowPatch()
default: // unknown or cursed
    deny()
}

How matching works

Kinds are stored and written in lowercase. Queries compare kinds without regard to case. Values compare by the rule for their kind:

Kind Rule
ssh exact bytes, no normalization
email local part exact, domain as DNS (RFC 5321)
username RFC 8265 PRECIS, exact if the profile rejects it
bare folded
anything else exact bytes, no normalization

Values for text kinds are normalized to NFC first, so two spellings of the same name are the same person. A fingerprint is an opaque token rather than text, and an unrecognized kind could be either, so neither is normalized. Value normalization happens when comparing, never when parsing, so a file is written back exactly as it was read.

An email domain is compared the way DNS sees it, so the Unicode and punycode spellings of a name are one domain: alice@münchen.de matches alice@xn--mnchen-3ya.de. A trailing root dot is ignored.

Lookalikes never match. A Latin alice is not a Cyrillic аlice, and the same holds for domains.

How resolution works

A file is sovereign over the lists it inherits. Its own entries win outright, so a project can bless someone an upstream list curses.

graph TD
    A["~alice/repo<br/>+dave"] -->|inherits| B["~org/shared-trust<br/>-dave"]
    A -->|decides| R(["maintainer"])

Among the inherited lists there is no such precedence. The strongest claim wins: cursed, then maintainer, then contributor. Inheriting a list means accepting its curses.

graph TD
    A["~alice/repo<br/>dave not listed"] -->|inherits| B["~org/shared-trust<br/>-dave"]
    A -->|inherits| C["~bob/friends<br/>+dave"]
    B -->|decides| R(["cursed"])

The outcome never depends on the order @ref lines appear in. Every inherited list is read, and ties go to the shallower source, then the lower ref name.

A missing file is an ordinary no-match. Anything else that cuts the search short is returned as an error: a loader failure, a chain longer than bless.WithMaxDepth allows, or a graph larger than bless.WithMaxRefs allows. The error comes back even when a role was found, because a branch that went unread could have contributed a stronger claim. A repeated ref is read once, so a loop terminates quietly.

Resolving one ref:

flowchart TD
    S([resolve ref]) --> D{"deeper than<br/>WithMaxDepth?"}
    D -->|yes| T[record as truncated] --> U[contributes nothing]
    D -->|no| V{"read already, at<br/>this depth or less?"}
    V -->|yes| U
    V -->|no| L[LoadTrust ref]
    L -->|ErrNotFound| U
    L -->|other error| E[collect error] --> U
    L -->|ok| M{"identity listed<br/>in this file?"}
    M -->|yes| R([that entry decides])
    M -->|no| I["resolve every @ref,<br/>keep the strongest"] --> R

License

Apache License Version 2.0

Documentation

Overview

Package bless manages trust lists for projects. Identities are "blessed" to grant access or "cursed" to block it.

Trust lives in a BLESS file, one identity per line. A leading + marks a maintainer, a leading - marks a curse, and a bare identity is a contributor. An @ref line inherits another project's list.

An identity is written "kind:value", where the kind is the namespace it belongs to: "email:alice@example.com", "ssh:SHA256:abc", "github:alice". Any prefix is a kind. Written with no prefix an identity has KindNone, which is its own namespace rather than a wildcard, so a bare entry never matches a qualified lookup. A leading colon escapes a bare value that contains one, so ":SHA256:abc" is the bare identity "SHA256:abc".

Use Parse to read a file and File.Check to look up one identity, or File.Lookup when you also want the entry the role came from and its reason. Resolver follows @ref inheritance across projects through a Loader.

A file is one ordered sequence of lines. Read it with File.Entries and File.Inherits, which return copies, and change it through File.Add, File.Remove, File.Bless and File.Curse, so an edit can never move an entry away from the comment above it.

bless answers what role an identity has. It does not model what a role may do, so pushing, releasing or moderating stay the caller's vocabulary.

Index

Constants

View Source
const BlessFile = "BLESS"

BlessFile is the conventional name of a project's trust file.

Variables

View Source
var ErrEmptyIdentity = errors.New("bless: empty identity")

ErrEmptyIdentity is returned by ParseIdentity when there is no value left after the kind.

View Source
var ErrInvalidIdentity = errors.New("bless: invalid identity")

ErrInvalidIdentity reports an identity that File.Encode cannot write and Parse read back unchanged.

View Source
var ErrInvalidReason = errors.New("bless: invalid reason")

ErrInvalidReason reports a reason that File.Encode cannot write and Parse read back unchanged.

View Source
var ErrInvalidRef = errors.New("bless: invalid inherit ref")

ErrInvalidRef reports an inherit ref that is empty or contains whitespace. Whitespace usually means a trailing comment, which the format does not support: comments go on their own line.

View Source
var ErrInvalidRole = errors.New("bless: entry has an unrecognized role")

ErrInvalidRole reports an entry whose Role is not one of the defined roles. There is no prefix to write for it, so it would encode as a plain line and read back as a contributor.

View Source
var ErrMaxDepth = errors.New("bless: maximum inheritance depth exceeded")

ErrMaxDepth reports that an inheritance chain was cut short by WithMaxDepth. The search is incomplete, so RoleUnknown means "not found within the limit" rather than "not trusted".

View Source
var ErrMaxRefs = errors.New("bless: maximum refs exceeded")

ErrMaxRefs reports that a search examined the maximum number of refs set by WithMaxRefs. The search is incomplete, so callers must fail closed.

View Source
var ErrNoLoader = errors.New("bless: resolver has no loader")

ErrNoLoader reports a resolver built without a Loader. The search cannot start, so callers must treat the result as unknown and fail closed.

View Source
var ErrNoRole = errors.New("bless: entry has no role")

ErrNoRole reports an entry whose Role was never set. The zero Role means "not found" when looking up, so it cannot also mean "contributor" when writing.

View Source
var ErrNotFound = errors.New("bless: trust file not found")

ErrNotFound is returned by Loader when a BLESS file does not exist. This is distinct from a parse error. A missing file is not a failure, it means no trust data is available at that ref.

Functions

This section is empty.

Types

type Entry

type Entry struct {
	// Role is the trust level: maintainer (+), contributor (no prefix), or cursed (-).
	Role Role

	// Identity is who the entry is about, qualified by its namespace.
	Identity Identity

	// Reason is an optional human-readable note, mainly used for curses.
	Reason string
}

Entry represents a single identity in a BLESS file.

func (Entry) Matches

func (e Entry) Matches(identity Identity) bool

Matches reports whether this entry is about the given identity.

type File

type File struct {
	// contains filtered or unexported fields
}

File is a BLESS file: one ordered sequence of lines, holding entries, inherit refs, comments and blanks in the order they were written.

The sequence is the whole document. There is no second view of it to keep in step, so an entry cannot drift away from the comment above it and File.Encode can write the file back with its structure intact.

Read it with File.Entries and File.Inherits, change it with File.Add, File.Remove, File.Bless and File.Curse. The zero File is an empty file, ready to use.

func Parse

func Parse(content []byte) (*File, error)

Parse reads a BLESS file from raw bytes. CRLF input is accepted and normalized to LF by File.Encode.

One entry per line: an optional role prefix, an identity, then an optional reason. A leading + is a maintainer, a leading - is a curse, and no prefix is a contributor. An @ref line inherits another project's list.

Format:

# Comment
+alice@example.com
bob@example.com
-spammer@example.com AI slop
email:carol@example.com
ssh:SHA256:abc
:SHA256:abc
@~alice/other-project

An identity is "kind:value", see ParseIdentity. Written bare it has KindNone, which is its own namespace rather than a wildcard.

A comment must be on its own line. There are no trailing comments, so an @ref containing whitespace is an error rather than a ref that silently never resolves.

func (*File) Add

func (f *File) Add(e Entry)

Add appends a new entry to the end of the file. The identity kind is stored lowercase.

Entries are not sorted. A BLESS file is a document, and moving an entry would slide it under whatever comment happens to sit above its new position. Order it by hand if you want it ordered.

func (*File) AddInherit

func (f *File) AddInherit(ref string)

AddInherit appends an inherit ref to the end of the file.

func (*File) Bless

func (f *File) Bless(identity Identity, reason string, asMaintainer bool)

Bless replaces every matching entry with one contributor or maintainer. asMaintainer selects the exact role, so false demotes a maintainer. reason replaces any prior reason.

func (*File) Check

func (f *File) Check(identity Identity) Role

Check returns the role for the given identity in this file only, or RoleUnknown if it is not listed. See File.Lookup for the deciding entry and its reason.

func (*File) Curse

func (f *File) Curse(identity Identity, reason string)

Curse blocks an identity. Any existing blessings are removed first.

func (*File) Encode

func (f *File) Encode() ([]byte, error)

Encode serializes the trust file back to BLESS format. Every non-empty document ends with a newline. If the file was parsed with Parse, comments and blank lines are preserved. Entries added via File.Add are appended after the last line.

Whatever Encode returns, Parse accepts and reads back with the same roles, identities and reasons. A file that could not satisfy that is an error, see File.Validate.

func (*File) Entries

func (f *File) Entries() []Entry

Entries returns the file's entries in document order.

The result is a copy. Changing it does not change the file, so a caller cannot reorder entries out from under the comments that describe them. Use File.Add, File.Remove, File.Bless and File.Curse to make changes.

func (*File) Inherits

func (f *File) Inherits() []Inherit

Inherits returns the file's inherit refs in document order. As with File.Entries, the result is a copy.

func (*File) Lookup

func (f *File) Lookup(identity Identity) (Entry, bool)

Lookup returns the entry that decides the given identity's role in this file, and whether one was found. It does not follow inheritance directives, use Resolver for that.

An identity may be listed more than once. The strongest claim wins, so a curse beats a blessing anywhere in the file. Among equals the first wins.

This is the only place a local claim is selected. A role and the entry it came from are chosen together, so they cannot disagree.

func (*File) Remove

func (f *File) Remove(identity Identity) bool

Remove deletes every entry matching the given identity, along with the lines they came from. Comments and blanks around them are left alone. Returns true if any entries were removed.

func (*File) RemoveInherit

func (f *File) RemoveInherit(ref string) bool

RemoveInherit deletes every inherit ref equal to ref, along with the lines they came from. Returns true if any were removed.

func (*File) Validate

func (f *File) Validate() error

Validate reports every entry and inherit that File.Encode could not write back faithfully. Problems are joined, so one call surfaces all of them.

A file from Parse is always valid. These are mistakes only reachable by building a File in Go, where a struct literal can leave a field unset.

type Identity

type Identity struct {
	// Kind is the namespace. Empty means the identity was written bare.
	Kind Kind
	// Value is the identifier within that namespace.
	Value string
}

Identity is a participant, qualified by the namespace it belongs to.

Two identities of different kinds never match, so "email:alice" and "username:alice" are different people. An identity written with no prefix has KindNone, which is its own namespace rather than a wildcard.

Identity is comparable, but == is not the same question as Identity.Matches. Two values that differ only by Unicode spelling are unequal yet match. Matches is the semantic comparison.

func Bare

func Bare(value string) Identity

Bare returns an identity with no kind. It is not a wildcard, it only matches other bare identities.

func Email

func Email(value string) Identity

Email returns an email identity.

func ParseIdentity

func ParseIdentity(s string) (Identity, error)

ParseIdentity reads the wire form, "kind:value" or "value".

A leading colon means an empty kind whose value contains a colon, so ":SHA256:abc" is the bare identity "SHA256:abc". Without it the text before the first colon is always the kind.

The kind is lowercased. The value is left as written.

func SSH

func SSH(value string) Identity

SSH returns an SSH fingerprint identity.

func Username

func Username(value string) Identity

Username returns a username identity.

func (Identity) Matches

func (i Identity) Matches(other Identity) bool

Matches reports whether two identities are the same participant.

Kinds must agree, compared without regard to case. Values are then compared by the rule for that kind:

ssh       exact bytes, no normalization
email     NFC, then the local part exactly and the domain as DNS sees
          it, which is IDN-aware and case-insensitive
username  the RFC 8265 PRECIS UsernameCaseMapped profile, exact if rejected
bare      NFC, then folded
any other exact bytes, no normalization

A fingerprint is an opaque token rather than text, and for an unrecognized kind there is no way to tell which it is, so neither is normalized. For an authorization library the dangerous direction is matching more than intended.

func (Identity) String

func (i Identity) String() string

String returns the canonical wire form.

A bare value containing a colon is written with the leading-colon escape, so it does not read back as a qualified identity.

type Inherit

type Inherit struct {
	// Ref is the target to inherit trust from.
	// Examples: "~alice/compiler", "~org/shared-trust"
	Ref string
}

Inherit represents a trust inheritance directive (@ref in a BLESS file).

type Kind

type Kind string

Kind is the namespace an identity belongs to. Its canonical form is lowercase. The set is open: any prefix before the first colon is a kind, so a new platform needs no release.

const (
	// KindNone is an identity written with no prefix. Compares folded.
	KindNone Kind = ""
	// KindEmail is an email address. Per RFC 5321 the local part compares
	// exactly and the domain as DNS, so Unicode and punycode spellings of a
	// domain are one domain.
	KindEmail Kind = "email"
	// KindSSH is an SSH key fingerprint. Compares as exact bytes, because
	// base64 is case-significant.
	KindSSH Kind = "ssh"
	// KindUsername is a username on some platform. Compares by the RFC 8265
	// PRECIS UsernameCaseMapped profile.
	KindUsername Kind = "username"
)

Kinds bless knows the comparison rules for. Any other prefix is still a valid kind, it just compares exactly.

type Loader

type Loader interface {
	// LoadTrust loads a BLESS file by reference.
	// The ref format is defined by the consumer:
	//   "~alice/my-repo"   - a repo on the forge
	//   "./path"           - a filesystem path (for CLI tools)
	//
	// Refs come from BLESS files and are untrusted input. A loader must reject
	// refs outside its allowed paths, schemes, hosts or namespaces before using
	// them in filesystem or network operations.
	//
	// Returns nil and [ErrNotFound] if the ref has no trust file, nil and the
	// error for anything else (permission denied, I/O).
	//
	// A nil error must come with a non-nil file. Returning neither is a broken
	// loader, which the resolver reports as an error rather than treating as a
	// ref with no trust data.
	//
	// Resolver serializes calls to LoadTrust. A returned file must not be mutated
	// while any resolution may be reading it.
	LoadTrust(ref string) (*File, error)
}

Loader is the interface consumers implement to provide trust files from their storage backend. A git forge reads from git trees, a CLI tool reads from the filesystem, tests use in-memory maps.

type Option

type Option func(*Resolver)

Option configures a Resolver at construction.

func WithMaxDepth

func WithMaxDepth(n int) Option

WithMaxDepth limits how deep the resolver follows inheritance chains. It bounds work on long chains. Repeated refs are handled separately, so a chain that loops back terminates regardless of this setting.

A chain cut short by this limit is reported as ErrMaxDepth, including at 0, since a ref that went unread is a ref that went unread.

The default is 10. Non-positive values disable inheritance, which still checks the file at the starting ref.

func WithMaxRefs

func WithMaxRefs(n int) Option

WithMaxRefs limits how many refs one resolution examines. This bounds work across broad graphs as WithMaxDepth bounds work across deep graphs. Repeated directives count each time they are examined.

A search cut short by this limit is reported as ErrMaxRefs. The default is 1000. Non-positive values allow only the starting ref.

type Resolver

type Resolver struct {
	// contains filtered or unexported fields
}

Resolver walks trust inheritance chains to determine an identity's role.

A Resolver is fixed once built, so one may be shared by concurrent Resolver.Resolve calls.

func NewResolver

func NewResolver(loader Loader, opts ...Option) *Resolver

NewResolver creates a resolver that loads BLESS files via the given Loader.

func (*Resolver) Resolve

func (r *Resolver) Resolve(ref string, identity Identity) (Result, error)

Resolve determines the trust role for an identity starting from the given ref.

Resolution order:

  1. Check the BLESS file at ref. Its own entries settle the question.
  2. Otherwise read every inherited list and combine them.
  3. Return RoleUnknown if nothing matches anywhere.

A file is sovereign over the lists it inherits. Its own entries win outright, so a project can bless someone an upstream list curses.

Among the inherited lists there is no such precedence. The strongest claim wins: cursed, then maintainer, then contributor. Inheriting a list means accepting its curses.

The outcome never depends on the order @ref lines appear in. Ties go to the shallower source, then to the lower ref name.

A missing file is an ordinary no-match: a Loader returning ErrNotFound contributes nothing and is not an error.

Any other reason the search did not finish is returned as an error: a loader failure, a chain cut short by WithMaxDepth, or a graph cut short by WithMaxRefs. The error is returned even when a role was found, because a branch that went unread could have contributed a stronger claim. Treat a non-nil error as "this answer may be wrong" and fail closed.

A ref is explored once per useful depth. A cycle terminates rather than erroring, and so does a diamond, which is the same shape from here.

type Result

type Result struct {
	// Role is the resolved trust level.
	Role Role
	// Source is the ref that provided the match (e.g. "~alice/repo").
	//
	// When several inherited lists match at the same role, this is the
	// shallowest of them, and the lowest ref name among equals.
	//
	// Empty if Role is RoleUnknown.
	Source string
	// Entry is the entry that decided Role, so its role and reason always
	// describe the claim this result acted on. Nil when Role is RoleUnknown.
	Entry *Entry
}

Result is the outcome of a trust resolution.

type Role

type Role int

Role represents a participant's trust level.

const (
	// RoleUnknown means the identity was not found in any trust file.
	RoleUnknown Role = iota
	// RoleContributor means the identity has been blessed (no prefix in BLESS).
	RoleContributor
	// RoleMaintainer means the identity has elevated trust (+ prefix in BLESS).
	// What that permits is the consumer's decision, bless does not model
	// actions.
	RoleMaintainer
	// RoleCursed means the identity has been explicitly blocked (- prefix in BLESS).
	RoleCursed
)

Declaration order carries no meaning. Precedence lives in [Role.rank], so these can be reordered or added to without changing how duplicates or inherited claims resolve.

func (Role) IsAllowed

func (r Role) IsAllowed() bool

IsAllowed reports whether the role permits participation. Maintainers and contributors are allowed. Cursed and unknown are not.

func (Role) String

func (r Role) String() string

String returns the string representation of the role.

Source Files

  • bless.go
  • doc.go
  • entry.go
  • identity.go
  • resolve.go
  • role.go

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL