Documentation
¶
Overview ¶
Package settings provides database-backed, typed application settings for SaaS applications, with values that can be overridden per tenant, per user or per any other scope.
Keys ¶
A setting is declared as a Key[T]: a dotted name, a default, an optional validation function and a secret flag. Keys are registered in a Registry, which rejects malformed and duplicate names and checks that the default survives a JSON round trip and passes validation:
var TrialDays = settings.Key[int]{
Name: "billing.trial_days",
Default: 14,
Validate: func(v int) error { ... },
}
reg, err := settings.NewRegistry(TrialDays)
Resolution ¶
A Client resolves a key against a chain of scopes. A Scope is a type and an ID, both always set; Global returns the one global scope. Options.Order lists the scope types from highest to lowest precedence (by default user, tenant, global); Get consults the scopes it is given in that order, then the global scope, then the key's default. Scope types outside the order fail with ErrInvalidScope. Resolve also reports which scope supplied the value. Set and SetJSON write an override at one scope, Unset deletes it, and All lists every registered key with its override at one scope for admin screens. Operations on unregistered keys fail with ErrUnknownKey.
Secrets ¶
Values of keys with Secret set are encrypted before they reach the store, through an Encrypter. AESGCM is the built-in implementation: AES-256-GCM with a random nonce per value and the key ID embedded in each ciphertext, so keys can be rotated while old values stay readable; Client.Reencrypt then moves every encrypted value in the table to the current key, and reports whether old keys can be retired. Each ciphertext is bound to its key name, scope and key ID, so a ciphertext copied to another row fails to decrypt; an older ciphertext of the same row, put back by someone with write access to the table, still decrypts while its key is in the keyring.
The package redacts secret values wherever it controls their output: All (unless RevealSecrets is passed), Change, error messages, and fmt, slog and encoding/json output of Key and Resolved. It cannot redact a Key or Resolved held in an unexported field of another struct, which fmt prints without calling their methods.
Caching ¶
Reads go through an in-process TTL cache, which also remembers that a scope has no override. Writes through a Client invalidate its own cache at once. Writes made by other processes are seen at most Options.CacheTTL after the cached lookup started, or as soon as their invalidation arrives when Options.Invalidations is set, for example to the pgxstore Listener, which relays PostgreSQL notifications. While such a source is disconnected, the TTL bound applies. Reads on a caller-supplied connection, such as a transaction joined through pgxstore.WithConn, bypass the cache (see CallerConnReporter).
Storage ¶
Store is the persistence interface. The memstore package holds an in-memory implementation for tests, pgxstore a PostgreSQL implementation, and storetest a conformance suite for third-party stores.
Auditing ¶
Options.OnChange receives a Change for every write that altered the store, carrying the old and new JSON values and the actor, a Ref such as {Type: "user", ID: "42"}. The actor comes from Options.ActorFrom, which defaults to reading the value stored by WithActor, so an application can plug in its own context lookup once.
Errors ¶
Every error wraps one of the family sentinels ErrNotFound, ErrInvalid or ErrConflict where one applies, through a package-specific error such as ErrUnknownKey or ErrInvalidScope.
Example ¶
package main
import (
"context"
"errors"
"fmt"
settings "github.com/byteminus/go-settings"
"github.com/byteminus/go-settings/memstore"
)
func main() {
ctx := context.Background()
trialDays := settings.Key[int]{
Name: "billing.trial_days",
Default: 14,
Description: "Length of the free trial",
Validate: func(v int) error {
if v < 1 || v > 90 {
return errors.New("must be between 1 and 90")
}
return nil
},
}
reg, err := settings.NewRegistry(trialDays)
if err != nil {
panic(err)
}
client, err := settings.New(memstore.New(), reg, settings.Options{})
if err != nil {
panic(err)
}
defer client.Close()
acme := settings.Tenant("acme")
days, _ := settings.Get(ctx, client, trialDays, acme)
fmt.Println("default:", days)
_ = settings.Set(ctx, client, trialDays, settings.Global(), 21)
_ = settings.Set(ctx, client, trialDays, acme, 30)
days, _ = settings.Get(ctx, client, trialDays, acme)
fmt.Println("acme:", days)
days, _ = settings.Get(ctx, client, trialDays, settings.Tenant("globex"))
fmt.Println("globex:", days)
err = settings.Set(ctx, client, trialDays, acme, 365)
fmt.Println(errors.Is(err, settings.ErrInvalidValue))
}
Output: default: 14 acme: 30 globex: 21 true
Index ¶
- Constants
- Variables
- func DefaultOrder() []string
- func Get[T any](ctx context.Context, c *Client, k Key[T], scopes ...Scope) (T, error)
- func Set[T any](ctx context.Context, c *Client, k Key[T], scope Scope, v T) error
- func ValidateKeyName(name string) error
- func WithActor(ctx context.Context, actor Ref) context.Context
- type AESGCM
- type AllOption
- type AnyKey
- type CallerConnReporter
- type Change
- type Client
- func (c *Client) All(ctx context.Context, scope Scope, opts ...AllOption) ([]Entry, error)
- func (c *Client) Close()
- func (c *Client) Reencrypt(ctx context.Context) (ReencryptResult, error)
- func (c *Client) SetJSON(ctx context.Context, name string, scope Scope, value json.RawMessage) error
- func (c *Client) Unset(ctx context.Context, name string, scope Scope) error
- type Encrypter
- type Entry
- type Invalidation
- type InvalidationSource
- type Key
- type KeyIdentifier
- type KeyInfo
- type Leftover
- type Options
- type Record
- type ReencryptResult
- type Ref
- type Registry
- type Resolved
- type Scope
- type Store
Examples ¶
Constants ¶
const ( DefaultCacheTTL = 30 * time.Second DefaultCacheSize = 10000 )
Defaults applied by New for zero-valued Options fields.
const ( MaxRefTypeLen = 64 MaxRefIDLen = 255 )
Limits on the parts of a Ref, shared by every Store. Lengths count Unicode code points, as PostgreSQL's length() does.
const ( ScopeGlobal = "global" ScopeTenant = "tenant" ScopeUser = "user" )
Well-known scope types. Any identifier matching [a-z][a-z0-9_]* can be used as a scope type as long as it appears in the client's resolution order; these are the ones the default order knows about.
const ( MaxKeyNameLen = 200 MaxScopeTypeLen = 64 MaxScopeIDLen = 255 )
Storage limits, shared by every Store so that the in-memory and PostgreSQL stores accept exactly the same records. Lengths count Unicode code points, as PostgreSQL's length() does.
const MaxLeftovers = 100
MaxLeftovers caps ReencryptResult.Leftovers, so a table full of values that cannot be re-encrypted does not produce an unbounded report; Remaining still counts all of them.
const MaxValueLen = 64 << 10
MaxValueLen caps the length in bytes of the JSON encoding of a value a client writes (before encryption), so a request body passed to SetJSON cannot make the client decode, encrypt or store an unbounded document.
const Redacted = "[redacted]"
Redacted replaces secret values wherever this package controls how they are exposed: listings, KeyInfo defaults and Change events carry the JSON string "[redacted]" instead of the value, and fmt, slog and encoding/json print it in place of the Default of a secret Key and the Value of a secret Resolved.
The package cannot reach a Key or Resolved held in an unexported field of another struct: fmt prints such fields by reflection without calling their methods, so %v of that outer struct shows the secret. Read Value only where it is needed and do not store a secret Resolved in a struct that may be printed.
Variables ¶
var ( // ErrNotFound reports that the record or key does not exist. ErrNotFound = errors.New("settings: not found") // ErrInvalid reports input that failed validation. The message names the // field that failed and why. ErrInvalid = errors.New("settings: invalid input") // ErrConflict reports a uniqueness violation, or a record whose state no // longer allows the operation. ErrConflict = errors.New("settings: conflict") )
Family sentinels, shared in name and meaning by every Byte Minus package so an application can map all of them to HTTP statuses the same way. Every package-specific error below wraps one of them where one applies.
var ( // ErrUnknownKey reports an operation on a key name that is not // registered. It wraps ErrNotFound. ErrUnknownKey = fmt.Errorf("settings: unknown key: %w", ErrNotFound) // ErrDuplicateKey reports an attempt to register a key name twice. It // wraps ErrConflict. ErrDuplicateKey = fmt.Errorf("settings: duplicate key: %w", ErrConflict) // ErrInvalidKey reports a malformed key name or a key definition whose // default value does not encode, decode or validate. It wraps ErrInvalid. ErrInvalidKey = fmt.Errorf("settings: invalid key: %w", ErrInvalid) // ErrTypeMismatch reports a Key[T] whose T differs from the type the key // name was registered with. It wraps ErrInvalid. ErrTypeMismatch = fmt.Errorf("settings: type mismatch: %w", ErrInvalid) // ErrInvalidScope reports a malformed scope, a scope type that is not part // of the client's resolution order, or the same scope type given twice. // It wraps ErrInvalid. ErrInvalidScope = fmt.Errorf("settings: invalid scope: %w", ErrInvalid) // ErrInvalidValue reports a value that fails its key's Validate function // or exceeds MaxValueLen, or a stored value that does not decode into the // key's type. It wraps ErrInvalid. ErrInvalidValue = fmt.Errorf("settings: invalid value: %w", ErrInvalid) // ErrInvalidRecord reports a record a Store refuses to persist because it // violates the storage constraints (see Record.Validate). It wraps // ErrInvalid. ErrInvalidRecord = fmt.Errorf("settings: invalid record: %w", ErrInvalid) // ErrNoEncrypter reports a secret key used with a client that has no // Encrypter. It wraps ErrInvalid. ErrNoEncrypter = fmt.Errorf("settings: secret key requires an encrypter: %w", ErrInvalid) // ErrDecrypt reports a secret value that cannot be decrypted: it is // malformed, was encrypted with an unknown key, or fails authentication, // for example because it was copied from another row. It wraps none of // the family sentinels: the stored data, not the caller's input, is at // fault. ErrDecrypt = errors.New("settings: decrypt failed") )
Package-specific errors. Errors returned by this package wrap one of these, so callers can test for them with errors.Is; the wrapped chain also carries the underlying cause (a Validate error, a store error) where there is one.
var ErrReencryptIncomplete = errors.New("settings: re-encryption incomplete")
ErrReencryptIncomplete reports a Reencrypt run after which some stored value may still need an old key; see ReencryptResult.
Functions ¶
func DefaultOrder ¶
func DefaultOrder() []string
DefaultOrder is the resolution order used when Options.Order is empty: the most specific scope wins.
func Get ¶
Get returns the value of k for the first of scopes (ordered by the client's resolution order, with the global scope added when the order includes it) that has one, or k's default. It fails with ErrUnknownKey for unregistered keys, ErrTypeMismatch when T differs from the registered type, ErrInvalidScope for malformed scopes, scopes outside the resolution order and a scope type given twice, ErrInvalidValue for a stored value that no longer decodes or validates, and ErrDecrypt for a secret that cannot be decrypted.
func Set ¶
Set stores v as the value of k at scope, after validating it. Secret values are encrypted. The actor from Options.ActorFrom is recorded as the author. It fails with ErrUnknownKey, ErrTypeMismatch, ErrInvalidScope (including a scope type outside the resolution order), ErrInvalidValue (Validate failed or the encoding exceeds MaxValueLen), ErrInvalid for a half-empty actor, ErrNoEncrypter, or the store's or the OnChange hook's error.
func ValidateKeyName ¶
ValidateKeyName reports whether name is a well-formed key name: one or more dot-separated segments, each matching [a-z][a-z0-9_]*, at most MaxKeyNameLen characters in total, such as "billing.trial_days". The error wraps ErrInvalidKey.
func WithActor ¶
WithActor returns a context that records actor (a user, an API client, a job) as the author of writes made with it. The actor is stored with each written value (Record.UpdatedBy) and passed to the OnChange hook. A zero actor records no author; a half-empty one makes writes fail with ErrInvalid.
Types ¶
type AESGCM ¶
type AESGCM struct {
// contains filtered or unexported fields
}
AESGCM is the built-in Encrypter: AES-256-GCM with key rotation. Each ciphertext embeds the ID of the key that produced it, so values written with retired keys still decrypt while new values use the current key; see Client.Reencrypt for moving old values to the current key so a retired key can be dropped.
Ciphertext layout: version (1 byte), key ID length (1 byte), key ID, 12-byte nonce, sealed data. The header is authenticated together with the caller's associated data, so the key ID cannot be swapped. Every encryption draws a fresh 96-bit nonce from crypto/rand; random nonces stay safe for about 2^32 encryptions under one key, far beyond what settings writes reach, and rotating the key resets the count.
What the binding does not stop: someone who can write to the settings table can put back an older ciphertext of the same row, for example from a backup, and it decrypts to that row's older value for as long as the key that sealed it is in the keyring. Treat write access to the table as trusted, keep backups as protected as the live table, and after rotating away from a key, remove it once Client.Reencrypt reports completion, which makes every ciphertext sealed under it unreadable.
AESGCM is safe for concurrent use.
func NewAESGCM ¶
NewAESGCM returns an AESGCM that encrypts with keys[currentKeyID] and decrypts with any key in keys. Every key must be 32 bytes; key IDs must be 1 to 255 bytes. The keys are copied. Configuration errors wrap ErrInvalid and never include key material.
func (*AESGCM) CurrentKeyID ¶
CurrentKeyID returns the ID of the key new values are encrypted with.
func (*AESGCM) Decrypt ¶
Decrypt opens a ciphertext produced by Encrypt with any known key. Failures wrap ErrDecrypt.
type AllOption ¶
type AllOption func(*allOptions)
AllOption configures All.
func RevealSecrets ¶
func RevealSecrets() AllOption
RevealSecrets makes All decrypt and include secret values and defaults instead of redacting them. Use it only where the caller is entitled to see secrets.
type AnyKey ¶
type AnyKey interface {
// contains filtered or unexported methods
}
AnyKey is implemented by every Key[T]; it lets a Registry hold keys of different types. It cannot be implemented outside this package.
type CallerConnReporter ¶ added in v0.2.0
CallerConnReporter is an optional interface for a Store that can run a call on a connection the caller supplies, such as a transaction carried in the context (see pgxstore.WithConn). pgxstore.Store implements it.
When UsesCallerConn reports true for a context, the client neither reads its cache nor fills it for that call: a read inside a caller's transaction can see an uncommitted write, or under REPEATABLE READ a snapshot older than the cache, and caching either would serve it to every other request after a rollback or a later commit. Reads on such a context always go to the store, inside the caller's transaction.
type Change ¶
type Change struct {
Key string
Scope Scope
Old json.RawMessage
New json.RawMessage
Secret bool
// Actor is the actor from Options.ActorFrom, or the zero Ref.
Actor Ref
At time.Time
}
Change describes a write, for the OnChange hook. Old is nil when the key had no value at Scope before the write and New is nil after an Unset. For secret keys, present values are replaced by the JSON string Redacted, so a Change can be printed, logged or JSON-encoded safely.
type Client ¶
type Client struct {
// contains filtered or unexported fields
}
Client reads and writes settings: it resolves values across scopes, validates and encrypts writes, caches reads and reports changes. A Client is safe for concurrent use.
func New ¶
New returns a Client over store for the keys in registry. Keys registered later are picked up automatically. It fails with an error wrapping ErrInvalid when store or registry is nil or an option is malformed, and with ErrNoEncrypter when a registered key is secret and there is no Encrypter.
func (*Client) All ¶
All lists every registered key, ordered by name, with the override stored at exactly scope (if any). It reads the store directly, bypassing the cache, so admin views are never stale. Stored values of keys that are no longer registered are skipped. It fails with ErrInvalidScope, the store's error, or, with RevealSecrets, ErrNoEncrypter or ErrDecrypt.
Example ¶
package main
import (
"bytes"
"context"
"fmt"
settings "github.com/byteminus/go-settings"
"github.com/byteminus/go-settings/memstore"
)
func main() {
ctx := settings.WithActor(context.Background(), settings.Ref{Type: "user", ID: "admin@example.com"})
theme := settings.Key[string]{Name: "ui.theme", Default: "light", Description: "Colour scheme"}
token := settings.Key[string]{Name: "mail.api_token", Secret: true}
reg, _ := settings.NewRegistry(theme, token)
enc, _ := settings.NewAESGCM("2026-10", map[string][]byte{"2026-10": bytes.Repeat([]byte{7}, 32)})
client, _ := settings.New(memstore.New(), reg, settings.Options{Encrypter: enc})
defer client.Close()
acme := settings.Tenant("acme")
_ = settings.Set(ctx, client, theme, acme, "dark")
_ = settings.Set(ctx, client, token, acme, "sk-123")
entries, _ := client.All(ctx, acme)
for _, e := range entries {
fmt.Printf("%s overridden=%t value=%s by=%s\n", e.Name, e.Overridden, e.Value, e.UpdatedBy)
}
}
Output: mail.api_token overridden=true value="[redacted]" by=user:admin@example.com ui.theme overridden=true value="dark" by=user:admin@example.com
func (*Client) Close ¶
func (c *Client) Close()
Close detaches the client from its invalidation source. Without remote invalidations a cache could serve stale values for up to its TTL, so Close also disables the cache; the client stays usable but reads go to the store. Close is idempotent.
func (*Client) Reencrypt ¶ added in v0.2.0
func (c *Client) Reencrypt(ctx context.Context) (ReencryptResult, error)
Reencrypt rewrites every encrypted value in the store with the Encrypter's current key. Run it after a rotation, once every process uses the new keyring. Only when it returns a result whose Complete method reports true (and so no error) may the old keys be removed, which is what makes retiring a compromised key possible.
It scans the whole table, not only the keys in this client's registry, so secrets written by other services sharing the table are covered:
- Values of registered secret keys are always rewritten (with a KeyIdentifier Encrypter such as AESGCM, only those not already sealed with the current key).
- Values of registered non-secret keys are left alone.
- Values of unregistered keys are recognized as encrypted from their stored envelope, which needs a KeyIdentifier Encrypter; otherwise they are counted in Unchecked. A non-secret value that happens to look exactly like an envelope is reported in Remaining rather than ignored.
Values are rewritten one at a time with Store.Swap, so a concurrent Set or Unset is never overwritten; a value that changed under the rewrite is re-read and retried. Rewriting keeps the record's UpdatedAt and UpdatedBy, since the setting did not change, and does not call OnChange. An interrupted run can simply be repeated.
A value that cannot be rewritten does not stop the run: it is counted and described in the result, and Reencrypt then returns the result with an error wrapping ErrReencryptIncomplete and the first leftover's cause (such as ErrDecrypt). It fails with ErrNoEncrypter when the client has no Encrypter, and stops with the store's error, returning the result so far.
Example ¶
package main
import (
"bytes"
"context"
"fmt"
settings "github.com/byteminus/go-settings"
"github.com/byteminus/go-settings/memstore"
)
func main() {
ctx := context.Background()
token := settings.Key[string]{Name: "mail.api_token", Secret: true}
reg, _ := settings.NewRegistry(token)
store := memstore.New()
oldKey, newKey := bytes.Repeat([]byte{1}, 32), bytes.Repeat([]byte{2}, 32)
before, _ := settings.NewAESGCM("2026-10", map[string][]byte{"2026-10": oldKey})
c1, _ := settings.New(store, reg, settings.Options{Encrypter: before})
_ = settings.Set(ctx, c1, token, settings.Tenant("acme"), "sk-123")
// Rotation: the new key is current, the old one still decrypts.
rotating, _ := settings.NewAESGCM("2026-11", map[string][]byte{"2026-10": oldKey, "2026-11": newKey})
c2, _ := settings.New(store, reg, settings.Options{Encrypter: rotating})
res, err := c2.Reencrypt(ctx)
fmt.Println("re-encrypted:", res.Rewritten, "complete:", res.Complete(), err)
// The old key is gone and every value still reads.
after, _ := settings.NewAESGCM("2026-11", map[string][]byte{"2026-11": newKey})
c3, _ := settings.New(store, reg, settings.Options{Encrypter: after})
v, err := settings.Get(ctx, c3, token, settings.Tenant("acme"))
fmt.Println(v, err)
}
Output: re-encrypted: 1 complete: true <nil> sk-123 <nil>
func (*Client) SetJSON ¶
func (c *Client) SetJSON(ctx context.Context, name string, scope Scope, value json.RawMessage) error
SetJSON stores a JSON-encoded value for the key registered under name, for admin UIs that do not have the typed Key at hand. The value is decoded into the key's type and validated exactly as Set would; it fails with ErrInvalidValue when value is longer than MaxValueLen or does not decode, and otherwise with the errors of Set.
func (*Client) Unset ¶
Unset deletes the override of the key registered under name at scope, so resolution falls through to the next scope. Unsetting a value that is not set is not an error and does not call OnChange. It fails with ErrUnknownKey, ErrInvalidScope, ErrInvalid for a half-empty actor, or the store's or the OnChange hook's error.
type Encrypter ¶
type Encrypter interface {
// Encrypt seals plaintext, bound to associatedData, with the current key.
Encrypt(ctx context.Context, plaintext, associatedData []byte) ([]byte, error)
// Decrypt opens a ciphertext produced by Encrypt with the same
// associatedData, using whichever key sealed it. Failures should wrap
// ErrDecrypt; the client wraps those that do not.
Decrypt(ctx context.Context, ciphertext, associatedData []byte) ([]byte, error)
}
Encrypter encrypts secret values at rest. AESGCM is the built-in implementation; applications implement Encrypter to keep keys in a KMS.
Implementations must be safe for concurrent use, must authenticate both the ciphertext and associatedData (an AEAD), and must not include plaintext in their errors. The client passes associatedData identifying the key name and scope a value belongs to, so a ciphertext copied to another row fails to decrypt. The context lets implementations make network calls; they should honour its deadline.
type Entry ¶
type Entry struct {
KeyInfo
// Overridden reports that the key has a value stored at the listed scope.
Overridden bool
// Value is the stored value when Overridden: JSON, or the Redacted marker
// for secret keys unless RevealSecrets was given.
Value json.RawMessage
UpdatedAt time.Time
// UpdatedBy is the actor that wrote the value, or the zero Ref.
UpdatedBy Ref
}
Entry is one registered key as seen at one scope, for admin listings.
type Invalidation ¶
Invalidation tells a cache that values may have changed elsewhere. All set means anything may have changed (for example after a lost connection); otherwise only the value of Key at Scope.
type InvalidationSource ¶
type InvalidationSource interface {
Subscribe(fn func(Invalidation)) (unsubscribe func())
}
InvalidationSource delivers invalidations published by other processes, such as the pgxstore Listener. Applications implement it to relay changes over their own bus.
Subscribe registers fn and returns a function that removes it. fn may be called from another goroutine and must return quickly. An implementation that may have missed changes (for example after losing its connection) must deliver an Invalidation with All set before relying on incremental ones again; until then, clients fall back to their cache TTL.
type Key ¶
type Key[T any] struct { // Name is the dotted key name, such as "billing.trial_days". Name string // Default is returned when no scope in the resolution chain has a value. Default T // Description is shown in admin listings. Description string // Validate, if set, is applied to Default at registration, to every value // written, and to every value read back from the store. Its error is // wrapped in the ErrInvalidValue error returned to the caller, except for // secret keys, where it is dropped because it may quote the value. Validate func(T) error // Secret values are encrypted at rest and redacted in listings, hooks, // errors, and fmt, slog and encoding/json output (see Redacted for the // one case the package cannot cover). Secret bool }
Key declares a typed setting. Values are stored as the JSON encoding of T and decoded strictly: unknown object fields and trailing data are errors.
A Key is a plain value; declare it once (typically as a package-level variable) and register it with a Registry. The registered definition is authoritative: operations only use the Key's Name and T.
func (Key[T]) Format ¶ added in v0.2.0
Format implements fmt.Formatter. It prints the key's fields, with Default replaced by Redacted when the key is secret, so a key logged by mistake cannot leak a secret default. Validate is not printed.
func (Key[T]) LogValue ¶ added in v0.2.0
LogValue implements slog.LogValuer, redacting the default of a secret key.
func (Key[T]) MarshalJSON ¶ added in v0.2.0
MarshalJSON implements json.Marshaler, encoding Name, Default, Description and Secret, with Default replaced by Redacted for a secret key. Validate is not encoded.
type KeyIdentifier ¶ added in v0.2.0
type KeyIdentifier interface {
// CurrentKeyID returns the ID of the key Encrypt uses.
CurrentKeyID() string
// KeyID returns the ID of the key that sealed ciphertext, and false when
// ciphertext is not in this Encrypter's format. It must not decrypt.
KeyID(ciphertext []byte) (string, bool)
}
KeyIdentifier is an optional interface for Encrypters that can read, from a ciphertext alone, which key sealed it. AESGCM implements it. Client.Reencrypt uses it to skip values already sealed with the current key and to find secrets of keys missing from its registry; with an Encrypter that does not implement it, values of unregistered keys cannot be classified (see ReencryptResult.Unchecked).
type KeyInfo ¶
type KeyInfo struct {
Name string
Description string
Secret bool
// Type is the Go type of the key's values, such as "int" or "[]string".
Type string
Default json.RawMessage
}
KeyInfo describes a registered key for admin UIs. Default is the JSON encoding of the key's default, or the Redacted marker for secret keys.
type Leftover ¶ added in v0.2.0
type Leftover struct {
Key string
Scope Scope
// Err says why, wrapping ErrDecrypt when the value does not decrypt. It
// never quotes the value.
Err error
}
Leftover is a value Reencrypt could not move to the current key.
type Options ¶
type Options struct {
// Order lists scope types from highest to lowest precedence. Get consults
// the scopes it is given in this order, then the key's default. When
// Order contains ScopeGlobal, the global scope is consulted even if the
// caller does not pass it. Scope types not listed cannot be read or
// written. Empty means DefaultOrder().
Order []string
// Encrypter encrypts secret keys at rest. It is required when any
// registered key is secret.
Encrypter Encrypter
// CacheTTL bounds how long a looked-up value is served from the
// in-process cache, counted from when the lookup started. Zero means
// DefaultCacheTTL; negative disables the cache. Writes through this
// client invalidate it immediately. Writes through other clients are
// seen within CacheTTL; with Invalidations set they are usually seen as
// soon as the invalidation arrives, and within CacheTTL while the source
// is disconnected.
CacheTTL time.Duration
// CacheSize caps the number of cached (key, scope) entries. Zero means
// DefaultCacheSize.
CacheSize int
// Invalidations, if set, delivers invalidations from other processes, so
// their writes are seen without waiting for CacheTTL.
Invalidations InvalidationSource
// OnChange, if set, is called after every successful Set, SetJSON and
// Unset that changed the store, for audit trails. Secret values are
// redacted. If it returns an error, the write has already happened; the
// error is returned to the caller, so a caller that writes inside a
// database transaction (see pgxstore.WithConn) can roll back both.
OnChange func(ctx context.Context, c Change) error
// ActorFrom returns the party performing a write, recorded as
// Record.UpdatedBy and Change.Actor. Nil means the package-level
// ActorFrom, which reads the value stored by WithActor. A half-empty
// Ref makes the write fail with ErrInvalid.
ActorFrom func(ctx context.Context) (Ref, bool)
// Now returns the current time; it stamps updated_at and drives cache
// expiry. Nil means time.Now.
Now func() time.Time
}
Options configures a Client. The zero value is ready to use.
Example (ActorFrom) ¶
package main
import (
"context"
"fmt"
settings "github.com/byteminus/go-settings"
"github.com/byteminus/go-settings/memstore"
)
func main() {
// The application's own context value, set by its auth middleware.
type userKey struct{}
theme := settings.Key[string]{Name: "ui.theme", Default: "light"}
reg, _ := settings.NewRegistry(theme)
client, _ := settings.New(memstore.New(), reg, settings.Options{
ActorFrom: func(ctx context.Context) (settings.Ref, bool) {
id, ok := ctx.Value(userKey{}).(string)
return settings.Ref{Type: "user", ID: id}, ok
},
})
defer client.Close()
ctx := context.WithValue(context.Background(), userKey{}, "42")
_ = settings.Set(ctx, client, theme, settings.Tenant("acme"), "dark")
r, _ := settings.Resolve(ctx, client, theme, settings.Tenant("acme"))
fmt.Println(r.Value, "set by", r.UpdatedBy)
}
Output: dark set by user:42
Example (OnChange) ¶
package main
import (
"context"
"encoding/json"
"fmt"
settings "github.com/byteminus/go-settings"
"github.com/byteminus/go-settings/memstore"
)
func main() {
ctx := settings.WithActor(context.Background(), settings.Ref{Type: "user", ID: "alice"})
limit := settings.Key[int]{Name: "api.rate_limit", Default: 60}
reg, _ := settings.NewRegistry(limit)
client, _ := settings.New(memstore.New(), reg, settings.Options{
OnChange: func(_ context.Context, c settings.Change) error {
fmt.Printf("%s changed %s at %s: %s -> %s\n", c.Actor, c.Key, c.Scope, orNone(c.Old), orNone(c.New))
return nil
},
})
defer client.Close()
_ = settings.Set(ctx, client, limit, settings.Tenant("acme"), 120)
_ = client.SetJSON(ctx, limit.Name, settings.Tenant("acme"), json.RawMessage(`240`))
_ = client.Unset(ctx, limit.Name, settings.Tenant("acme"))
}
func orNone(v json.RawMessage) string {
if v == nil {
return "none"
}
return string(v)
}
Output: user:alice changed api.rate_limit at tenant:acme: none -> 120 user:alice changed api.rate_limit at tenant:acme: 120 -> 240 user:alice changed api.rate_limit at tenant:acme: 240 -> none
type Record ¶
type Record struct {
Key string
Scope Scope
Value json.RawMessage
UpdatedAt time.Time
// UpdatedBy is the actor that wrote the value, or the zero Ref.
UpdatedBy Ref
}
Record is one stored override: the value of a key at a scope. Value holds JSON; for secret keys it holds the encrypted envelope, never the plaintext. Stores may normalize the JSON text (PostgreSQL jsonb does), so compare values by decoding them rather than byte for byte.
func (Record) Validate ¶
Validate reports whether r satisfies the storage constraints every Store enforces: a valid key name, a valid scope, a non-empty valid JSON value that PostgreSQL jsonb can store (no \u0000 escapes, no unpaired UTF-16 surrogate escapes, no numbers beyond numeric's 131072 integer and 16383 fractional digits), and an UpdatedBy that is zero or has both parts set, each valid UTF-8 without NUL bytes and within MaxRefTypeLen and MaxRefIDLen. The error wraps ErrInvalidRecord.
type ReencryptResult ¶ added in v0.2.0
type ReencryptResult struct {
// Rewritten counts the values re-sealed with the current key.
Rewritten int
// Remaining counts the encrypted values that are still not known to be
// sealed with the current key: values that do not decrypt with this
// keyring, and values that kept changing under the rewrite.
Remaining int
// Unchecked counts values of keys missing from the client's registry
// that the client could not classify as encrypted or not, because its
// Encrypter does not implement KeyIdentifier.
Unchecked int
// Leftovers describes the first MaxLeftovers values counted in
// Remaining.
Leftovers []Leftover
}
ReencryptResult reports what Client.Reencrypt did and, above all, whether any stored value may still need a key other than the current one.
func (ReencryptResult) Complete ¶ added in v0.2.0
func (r ReencryptResult) Complete() bool
Complete reports that no stored value needs a key other than the current one, so the old keys can be removed from the keyring.
type Ref ¶ added in v0.2.0
Ref identifies a record or party by type and ID, such as the user, API client or job that wrote a setting. Typed references keep "user 42" and "api_token 42" apart. A stored Ref is either zero (no actor) or has both fields set.
func ActorFrom ¶
ActorFrom returns the actor recorded by WithActor. The boolean is false when ctx carries none.
type Registry ¶
type Registry struct {
// contains filtered or unexported fields
}
Registry holds the set of known keys. It is safe for concurrent use; keys can be registered at any time, but are typically registered at startup. The zero value is an empty registry ready to use.
func NewRegistry ¶
NewRegistry returns a registry holding keys, or an error if any of them is malformed or duplicated.
func (*Registry) MustRegister ¶
MustRegister is like Register but panics on error. It is meant for package-level key declarations, where an error is a programming mistake.
type Resolved ¶
type Resolved[T any] struct { Value T // Scope is the scope the value was stored at; zero when Default is set. Scope Scope // Default reports that no scope had a value and Value is the key's // default. Default bool UpdatedAt time.Time // UpdatedBy is the actor that wrote the value, or the zero Ref. UpdatedBy Ref // contains filtered or unexported fields }
Resolved is a value together with where it came from. For a secret key, fmt, slog and encoding/json print Redacted in place of Value; a Resolved held in an unexported field of another struct is printed by fmt without that redaction (see Redacted).
func Resolve ¶
Resolve is Get, also reporting which scope the value came from, for admin UIs that show where an effective value is set. It returns the same errors as Get.
Example ¶
package main
import (
"context"
"fmt"
settings "github.com/byteminus/go-settings"
"github.com/byteminus/go-settings/memstore"
)
func main() {
ctx := context.Background()
theme := settings.Key[string]{Name: "ui.theme", Default: "light"}
reg, _ := settings.NewRegistry(theme)
client, _ := settings.New(memstore.New(), reg, settings.Options{})
defer client.Close()
_ = settings.Set(ctx, client, theme, settings.Tenant("acme"), "dark")
r, _ := settings.Resolve(ctx, client, theme, settings.User("u1"), settings.Tenant("acme"))
fmt.Println(r.Value, "from", r.Scope)
}
Output: dark from tenant:acme
func (Resolved[T]) Format ¶ added in v0.2.0
Format implements fmt.Formatter. It prints the resolved fields, with Value replaced by Redacted when the key is secret. Read r.Value directly to use the value.
func (Resolved[T]) LogValue ¶ added in v0.2.0
LogValue implements slog.LogValuer, redacting the value of a secret key.
func (Resolved[T]) MarshalJSON ¶ added in v0.2.0
MarshalJSON implements json.Marshaler, encoding every field, with Value replaced by Redacted for a secret key. slog's JSON handler uses it too, for example for a slice of Resolved values.
type Scope ¶
Scope identifies where a setting value applies: the global scope, or a typed owner such as a tenant or a user. Both fields are always set; the global scope is {Type: "global", ID: "global"}, as returned by Global.
func (Scope) String ¶
String returns "global" for the global scope and "type:id" otherwise, for logs and error messages only: it is not unique, so never use it as a key.
func (Scope) Validate ¶
Validate reports whether s is well formed: a type matching [a-z][a-z0-9_]* of at most MaxScopeTypeLen characters and a non-empty valid UTF-8 ID without NUL bytes of at most MaxScopeIDLen characters, which for the global type must be the ID Global uses. The zero Scope is invalid. The error wraps ErrInvalidScope.
type Store ¶
type Store interface {
// Get returns the records stored for key at any of scopes, in no
// particular order. Scopes without a record are omitted; a scope listed
// twice yields one record. No scopes yields no records.
Get(ctx context.Context, key string, scopes []Scope) ([]Record, error)
// List returns every record stored at scope, ordered by key.
List(ctx context.Context, scope Scope) ([]Record, error)
// Scan returns up to limit records, of any key, that sort after the
// position (afterKey, afterScope), in ascending order of (key, scope
// type, scope ID) under a total order the store defines (byte order, or
// a database collation) and keeps stable across calls. The empty key
// and zero Scope sort first and start from the beginning; passing the
// key and scope of the last record returned continues from there, so
// every record present for the whole scan is returned exactly once.
// limit is at least 1.
Scan(ctx context.Context, afterKey string, afterScope Scope, limit int) ([]Record, error)
// Put inserts or replaces the record for (rec.Key, rec.Scope) atomically
// and returns the record it replaced, or nil if there was none. Concurrent
// Puts on the same key and scope must serialize: each one observes the
// value written by the one before it. Records failing Record.Validate are
// rejected with an error wrapping ErrInvalidRecord.
Put(ctx context.Context, rec Record) (*Record, error)
// Swap replaces the value of the record for (key, scope) with value,
// keeping its UpdatedAt and UpdatedBy, only if the stored value still
// equals old, a Value this store returned earlier. It reports whether it
// replaced the value; a missing record or a different value is not an
// error. The check and the write are one atomic step, so a concurrent
// Put is never overwritten. A value failing Record.Validate is rejected
// with an error wrapping ErrInvalidRecord.
Swap(ctx context.Context, key string, scope Scope, old, value json.RawMessage) (bool, error)
// Delete removes the record for (key, scope) and returns it, or returns
// nil without error if there was none.
Delete(ctx context.Context, key string, scope Scope) (*Record, error)
}
Store persists overrides. Applications implement it to keep settings in a database this module does not support; the storetest package holds a conformance suite every implementation must pass.
Implementations must be safe for concurrent use, return UpdatedAt in UTC with at least microsecond precision, never alias the Value slices they are given or return, and honour context cancellation.
Source Files
¶
Directories
¶
| Path | Synopsis |
|---|---|
|
Package memstore is an in-memory settings.Store for tests and examples.
|
Package memstore is an in-memory settings.Store for tests and examples. |
|
Package pgxstore is a PostgreSQL settings.Store built on pgx v5.
|
Package pgxstore is a PostgreSQL settings.Store built on pgx v5. |
|
Package storetest is a conformance suite for settings.Store implementations.
|
Package storetest is a conformance suite for settings.Store implementations. |