Skip to content

About

Typed, database-backed application settings for Go: global, tenant and user scopes, encrypted secrets, and caching with Postgres LISTEN/NOTIFY invalidation.

Topics

Resources

Contributing

Stars

1 star

Watchers

0 watching

Forks

Latest commit

 

History

2 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

go-settings

tests Go Reference

Database-backed, typed application settings for SaaS applications. Declare a setting once as a Key[T] with a default and a validation rule, then let operators override it globally, per tenant or per user, with the most specific value winning. Values are checked on the way in and on the way out, secrets are encrypted at rest with rotating AES-256-GCM keys, reads are served from an in-process cache that PostgreSQL LISTEN/NOTIFY keeps coherent across instances, and every change can feed your audit log.

Install

go get github.com/byteminus/go-settings

Requires Go 1.26 or later. CI runs the PostgreSQL store against PostgreSQL 16, 17 and 18.

Quick start

package main

import (
	"context"
	"errors"
	"log"
	"os"

	"github.com/jackc/pgx/v5"
	"github.com/jackc/pgx/v5/pgxpool"

	settings "github.com/byteminus/go-settings"
	"github.com/byteminus/go-settings/pgxstore"
)

var TrialDays = settings.Key[int]{
	Name:        "billing.trial_days",
	Default:     14,
	Description: "Length of the free trial in days",
	Validate: func(v int) error {
		if v < 1 || v > 90 {
			return errors.New("must be between 1 and 90")
		}
		return nil
	},
}

func main() {
	ctx := context.Background()
	url := os.Getenv("DATABASE_URL")
	pool, err := pgxpool.New(ctx, url)
	if err != nil {
		log.Fatal(err)
	}
	defer pool.Close()

	store, err := pgxstore.New(pool)
	if err != nil {
		log.Fatal(err)
	}
	if err := store.CreateSchema(ctx); err != nil {
		log.Fatal(err)
	}

	// The listener keeps this process's cache coherent with writes made by
	// other instances.
	listener, err := pgxstore.NewListener(pgxstore.ListenerConfig{
		Connect: func(ctx context.Context) (*pgx.Conn, error) { return pgx.Connect(ctx, url) },
		OnError: func(err error) { log.Printf("settings listener: %v", err) },
	})
	if err != nil {
		log.Fatal(err)
	}
	go func() { _ = listener.Run(ctx) }()

	reg, err := settings.NewRegistry(TrialDays)
	if err != nil {
		log.Fatal(err)
	}
	client, err := settings.New(store, reg, settings.Options{Invalidations: listener})
	if err != nil {
		log.Fatal(err)
	}
	defer client.Close()

	acme := settings.Tenant("acme")
	admin := settings.WithActor(ctx, settings.Ref{Type: "user", ID: "admin@example.com"})
	if err := settings.Set(admin, client, TrialDays, acme, 30); err != nil {
		log.Fatal(err)
	}

	days, err := settings.Get(ctx, client, TrialDays, settings.User("u-42"), acme)
	if err != nil {
		log.Fatal(err)
	}
	log.Printf("trial: %d days", days) // 30, from the tenant override
}

Concepts

Keys. A Key[T] has a dotted Name (segments matching [a-z][a-z0-9_]*, such as billing.trial_days), a Default, a Description, an optional Validate func(T) error and a Secret flag. Keys are registered in a Registry, which rejects malformed and duplicate names and checks that the default encodes to JSON, decodes back and passes Validate. Registration is all or nothing. Every operation on an unregistered name fails with ErrUnknownKey; using a key name with a different T fails with ErrTypeMismatch.

Values. Values are stored as JSON and decoded strictly into T: unknown object fields and trailing data are errors. Validate runs on every write and on every read, so a stored value that no longer satisfies the rules surfaces as ErrInvalidValue instead of reaching your code. A value's JSON encoding may be at most MaxValueLen (64 KiB).

Scopes and resolution. A Scope is a type and an ID, both always set: settings.Global() (which is {Type: "global", ID: "global"}), settings.Tenant(id), settings.User(id) or any custom type such as {Type: "workspace", ID: id}. Options.Order lists scope types from highest to lowest precedence; the default is user, tenant, global. Get(ctx, client, key, scopes...) consults the given scopes in that order (argument order does not matter), then the global scope (added automatically), then the default. Resolve returns the same value plus the scope it came from, its UpdatedAt and UpdatedBy, for admin screens that show where an effective value is set. Put the global scope first in Order to make operator values override tenant choices.

Writes. Set writes a typed value at one scope; SetJSON writes by name from JSON for admin forms; Unset deletes an override so resolution falls through. All(ctx, scope) lists every registered key with its metadata and the override stored at exactly that scope.

Secrets. Values of Secret keys are encrypted before they reach the store through an Encrypter. The built-in AESGCM uses AES-256-GCM with a fresh random 96-bit nonce per value and embeds the key ID in each ciphertext: construct it with the current key ID and every key still in use, and old values keep decrypting while new ones use the current key. Each ciphertext is bound to its key name, scope and key ID through GCM's additional data, so a value copied to another row, scope or key fails to decrypt.

The package redacts secret values wherever it controls their output: All returns the "[redacted]" marker for secret values and defaults unless called with settings.RevealSecrets(), the OnChange hook and Change (printed or JSON-encoded) always carry the marker, error messages never quote a secret value, and fmt, slog and encoding/json print the marker for the default of a secret Key and the value of a secret Resolved. Only Get, Resolve (its Value field) and All with RevealSecrets return plaintext. What the package cannot reach: fmt prints an unexported struct field by reflection without calling its methods, so a secret Resolved or Key stored in an unexported field of your own struct shows in %v of that struct; keep only the values you need, where you need them.

To rotate keys, give every process a keyring whose current key is the new one and that still holds the old ones, then call client.Reencrypt(ctx) once. It scans the whole table, including keys only other services register, rewrites every encrypted value with the current key without overwriting concurrent writes, and keeps who last updated each value. Remove the old keys only when the result's Complete() reports true; otherwise Leftovers lists the values that still need them. This is how you retire a compromised key.

func rotate(ctx context.Context, store settings.Store, reg *settings.Registry, oldKey, newKey []byte) error {
	enc, err := settings.NewAESGCM("2026-11", map[string][]byte{
		"2026-10": oldKey, // still needed to read values written before the rotation
		"2026-11": newKey, // encrypts everything written from now on
	})
	if err != nil {
		return err
	}
	client, err := settings.New(store, reg, settings.Options{Encrypter: enc})
	if err != nil {
		return err
	}
	defer client.Close()
	res, err := client.Reencrypt(ctx)
	if err != nil {
		// Not complete: res.Leftovers says which values still need 2026-10.
		return err
	}
	log.Printf("re-encrypted %d secrets; key 2026-10 can now be removed", res.Rewritten)
	return nil
}

Security model. Encryption protects secret values from anyone who can read the settings table, its backups or its replicas without holding the keys. It does not protect against someone who can write to the table: they cannot forge or move a ciphertext to another row, scope or key, but they can put back an older ciphertext of the same row (from a backup, for example), 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, protect backups like the live table, and after a rotation remove retired keys as soon as Reencrypt reports completion, which makes every ciphertext sealed under them unreadable.

Cache. Reads go through an in-process cache (default TTL 30 seconds, 10,000 entries) that also remembers which scopes have no override, so the common "no user override" lookup costs nothing. Writes through a client invalidate its own cache immediately. A write made through another client is seen no later than CacheTTL after the cached lookup started, and as soon as its notification arrives when Options.Invalidations is set to a pgxstore.Listener. The listener reconnects with jittered backoff after connection loss, tells the cache to drop everything whenever notifications may have been missed, and reports every failure to ListenerConfig.OnError; while it is disconnected the CacheTTL bound applies. A negative CacheTTL disables the cache.

Auditing. Options.OnChange is called after every write that changed the store with a Change: key, scope, old and new JSON (nil when absent), the actor, and the time. The actor is a settings.Ref (a type and an ID, such as {Type: "user", ID: "42"} or {Type: "job", ID: "nightly-sync"}), stored with the value as UpdatedBy. If the hook returns an error, Set returns it; the write has already happened, so if you need the audit record and the change to be atomic, run both in one transaction (below).

Actor wiring. By default the actor is whatever settings.WithActor put in the context. An application that already keeps its user in the context can supply the lookup once instead, through Options.ActorFrom, which has the same signature as settings.ActorFrom:

type userKey struct{}

type User struct{ ID string }

func newClient(store settings.Store, reg *settings.Registry) (*settings.Client, error) {
	return settings.New(store, reg, settings.Options{
		ActorFrom: func(ctx context.Context) (settings.Ref, bool) {
			u, ok := ctx.Value(userKey{}).(User)
			return settings.Ref{Type: "user", ID: u.ID}, ok
		},
	})
}

PostgreSQL

pgxstore.New(db, opts...) accepts anything with pgx's Exec, Query and QueryRow (*pgxpool.Pool, *pgx.Conn, pgx.Tx). Options:

  • WithTable("app.settings"): table name, optionally schema-qualified.
  • WithChannel("settings_changed"): notification channel; "" disables notifications.
  • WithConn(fn): resolve the connection per call (see below).

CreateSchema(ctx) runs this idempotent DDL (Schema() returns it for migration tools):

CREATE TABLE IF NOT EXISTS "settings" (
	key             text        NOT NULL CHECK (key ~ '^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)*$' AND length(key) <= 200),
	scope_type      text        NOT NULL CHECK (scope_type ~ '^[a-z][a-z0-9_]*$' AND length(scope_type) <= 64),
	scope_id        text        NOT NULL CHECK (scope_id <> '' AND length(scope_id) <= 255),
	value           jsonb       NOT NULL,
	updated_at      timestamptz NOT NULL,
	updated_by_type text        NOT NULL DEFAULT '' CHECK (length(updated_by_type) <= 64),
	updated_by_id   text        NOT NULL DEFAULT '' CHECK (length(updated_by_id) <= 255),
	PRIMARY KEY (key, scope_type, scope_id),
	CHECK (scope_type <> 'global' OR scope_id = 'global'),
	CHECK ((updated_by_type = '') = (updated_by_id = ''))
);
CREATE INDEX IF NOT EXISTS "settings_scope_idx" ON "settings" (scope_type, scope_id);

Upgrading a 0.1.0 table: see the SQL in CHANGELOG.md.

Put is an upsert that is safe under concurrency and returns the value it replaced (for the OnChange hook): a locking UPDATE that returns the old row, falling back to INSERT ... ON CONFLICT DO NOTHING, retried if another writer wins the race in between. Each write publishes its notification in the same statement, so notifications are sent only for writes that commit. Swap, used by Reencrypt, is a single conditional UPDATE: it re-checks the expected value after waiting for any concurrent writer, so it never overwrites a value it did not see.

Joining transactions

WithConn lets settings writes join a transaction your code carries in its context, so a setting change, your own audit row and anything else commit or roll back together:

type txKey struct{}

func newStore(pool *pgxpool.Pool) (*pgxstore.Store, error) {
	return pgxstore.New(pool, pgxstore.WithConn(func(ctx context.Context) pgxstore.Querier {
		if tx, ok := ctx.Value(txKey{}).(pgx.Tx); ok {
			return tx
		}
		return nil // use the pool
	}))
}

func extendTrial(ctx context.Context, pool *pgxpool.Pool, client *settings.Client, trialDays settings.Key[int]) error {
	tx, err := pool.Begin(ctx)
	if err != nil {
		return err
	}
	defer func() { _ = tx.Rollback(ctx) }() // no-op after Commit
	txCtx := context.WithValue(ctx, txKey{}, tx)
	// OnChange receives txCtx too and can insert the audit row with the same tx.
	if err := settings.Set(txCtx, client, trialDays, settings.Tenant("acme"), 30); err != nil {
		return err
	}
	return tx.Commit(ctx)
}

Notifications are sent only on commit, so other instances never see a rolled-back write. Reads made with a context for which WithConn returns a connection run inside your transaction and bypass the client's cache in both directions (pgxstore.Store implements settings.CallerConnReporter), so a value read there, uncommitted or from an older REPEATABLE READ snapshot, is never served to other requests. The writing client invalidates its own cache when Set returns; if another request reads the key before the commit, it caches the old committed value until the commit notification arrives (when the client runs a Listener) or the TTL expires.

API overview

Identifier Purpose
Key[T], AnyKey Typed setting declaration
Registry, NewRegistry, Register, MustRegister, Lookup, Keys, KeyInfo Known keys and their metadata
Scope, Global, Tenant, User, ValidateKeyName Scopes and name rules
New, Options, Client, Close, DefaultOrder, DefaultCacheTTL, DefaultCacheSize Client construction
Get, Resolve, Resolved[T] Typed reads
Set, Client.SetJSON, Client.Unset, MaxValueLen Writes
Client.All, Entry, RevealSecrets Admin listing
Encrypter, KeyIdentifier, AESGCM, NewAESGCM Encryption at rest
Client.Reencrypt, ReencryptResult, Leftover, MaxLeftovers Key rotation
Change, Ref, WithActor, ActorFrom, Options.ActorFrom, Redacted Auditing
Store, Record, CallerConnReporter, Invalidation, InvalidationSource Storage and cache extension points
memstore.New In-memory store for tests
pgxstore.New, WithTable, WithChannel, WithConn, Schema, CreateSchema PostgreSQL store
pgxstore.NewListener, ListenerConfig, Listener.Run, Listener.Subscribe Cross-instance invalidation
storetest.Run Store conformance suite

Errors are checkable with errors.Is. Every package error wraps one of the family sentinels ErrNotFound (ErrUnknownKey), ErrConflict (ErrDuplicateKey, pgxstore.ErrContention) or ErrInvalid (ErrInvalidKey, ErrTypeMismatch, ErrInvalidScope, ErrInvalidValue, ErrInvalidRecord, ErrNoEncrypter and configuration errors), so an application can map them to HTTP statuses once. ErrDecrypt (stored data that cannot be decrypted), ErrReencryptIncomplete and pgxstore.ErrListenerRunning wrap none.

Testing

Use memstore.New() in unit tests; it enforces the same constraints as PostgreSQL. To verify your own Store, run the conformance suite:

func TestMyStore(t *testing.T) {
	storetest.Run(t, func(t *testing.T) settings.Store { return newMyStore(t) })
}

This repository's own tests run with go test -race ./...; set TEST_DATABASE_URL to include the PostgreSQL integration tests (see CONTRIBUTING.md).

Roadmap

  • Change history table and point-in-time reads.
  • Per-key cache TTLs and bulk reads of many keys in one query.
  • An embeddable admin HTTP handler built on All, SetJSON and Unset.
  • Scope resolution from the request context (tenant and user middleware).

Stability

This package follows semantic versioning. Before v1.0.0, a minor release may contain breaking changes; each one is listed in CHANGELOG.md with upgrade steps, including SQL for schema changes. From v1.0.0, the exported API and the PostgreSQL schema change only in backwards-compatible ways until the next major version.

License

MIT; see LICENSE.

About

Typed, database-backed application settings for Go: global, tenant and user scopes, encrypted secrets, and caching with Postgres LISTEN/NOTIFY invalidation.

Topics

Resources

Contributing

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages