# mamori documentation > Typed, validated, watchable configuration and secrets for Go, loaded from a broad provider ecosystem (env, files, AWS, Vault, GCP, Azure, Kubernetes, databases, feature flags) and reconciled at runtime without a restart. The entire mamori documentation, concatenated for agent consumption. --- > Source: https://mamorigo.dev/docs # Introduction `mamori` (守り, "protection") loads typed, validated configuration and secrets into a Go struct from a broad provider ecosystem (environment, files, AWS, Vault, GCP, Azure, Kubernetes, Consul, and more), then keeps that struct reconciled while your program runs. Reach for it when you want one struct to describe every value your service needs, revalidated and hot-swapped whenever a source changes, without a restart. ## Install The core module ships the `env:` and `file://` providers and has zero cloud-SDK dependencies: ```bash go get github.com/xavidop/mamori ``` Requires Go 1.26 or newer. Each cloud or backend provider is a separate module, so its SDK only enters your build if you actually use it: ```bash go get github.com/xavidop/mamori/providers/aws # aws-sm:// aws-ps:// go get github.com/xavidop/mamori/providers/vault # vault:// go get github.com/xavidop/mamori/providers/k8s # k8s-secret:// k8s-cm:// ``` See the [Providers overview](/docs/providers/) for the full list of schemes. ## Quick start Tag a struct with `source:` refs, then call `Load`. A blank import registers a provider (the `database/sql` pattern): ```go package main import ( "context" "log" "github.com/xavidop/mamori" "github.com/xavidop/mamori/secret" _ "github.com/xavidop/mamori/providers/aws" // registers aws-sm:// and aws-ps:// ) type Config struct { DBPassword secret.String `source:"aws-sm://prod/db#password"` LogLevel string `source:"env:LOG_LEVEL" default:"info" validate:"oneof=debug info warn error"` Workers int `source:"env:WORKERS" default:"4" validate:"gte=1,lte=256"` TLSCert []byte `source:"file:///etc/tls/tls.crt"` } func main() { cfg, err := mamori.Load[Config](context.Background()) if err != nil { log.Fatal(err) } log.Printf("workers=%d level=%s password=%s", cfg.Workers, cfg.LogLevel, cfg.DBPassword) // password prints as [REDACTED]; cfg.DBPassword.Reveal() returns the value. } ``` To react to changes at runtime instead of loading once, see [Loading & watching](/docs/usage/). ## How it works The model has three moving parts: 1. **Refs.** Each struct field carries a `source` tag: a small URL-ish reference to a value in some provider (`aws-sm://prod/db#password`, `env:LOG_LEVEL`, `file:///etc/tls/tls.crt`). 2. **Providers.** A provider resolves a scheme (`aws-sm`, `vault`, `env`, ...) into a `Value`. Providers register with the `database/sql` pattern, so the core module keeps zero cloud-SDK dependencies. 3. **The reconciler.** `Watch` resolves everything once (fail-fast), then watches each source: natively where the backend can push, by polling with jitter otherwise. On a change it re-validates the whole struct and, only if the result is valid, atomically swaps it in and fires your callback. ```mermaid flowchart LR Struct["Config struct with source: tags"] --> R["Reconciler (Load / Watch)"] R --> P["Providers, one per scheme"] P --> B["env, files, AWS, Vault, GCP, ..."] B -->|value or change| R R -->|"validate, then atomic swap"| Struct R -.->|on change| CB["your OnChange callback"] ``` The payoff: rotate a database password in Secrets Manager and your connection pool rotates with it, without a restart and without a half-applied config ever being observed. ## Where to go next - **[Concepts](/docs/concepts/):** refs, providers, the reconciler, and the full `source`/`default`/`validate` tag grammar. - **[Loading & watching](/docs/usage/):** one-shot `Load` versus a live `Watch`, change events, source chains, and snapshot pinning. - **[Validation](/docs/validation/):** the `validate:` rules applied on load and on every update. - **[Providers overview](/docs/providers/):** every scheme, its watch strategy, and how to authenticate it. - **[Config server](/docs/server/):** the opt-in module that serves resolved values to other callers behind mandatory auth. - **[CLI](/docs/cli/):** `explain`, `schema`, and `policy` read your source statically; `doctor` and `status` probe a running process. - **[Observability](/docs/observability/):** `Status`, `Health`, the pre-deploy `Doctor` check, and the admin HTTP endpoint. - **[Security](/docs/security/):** secret hygiene, the two HTTP surfaces, and what `mamori` is deliberately not (it is not a secrets store). --- > Source: https://mamorigo.dev/docs/quickstart # Quick start From zero to typed, validated, hot-reloading config in a few minutes. This walks through installing mamori, loading a config struct once, then upgrading to a live watch. ## 1. Install The core module ships the `env:` and `file://` providers with zero cloud-SDK dependencies: ```bash go get github.com/xavidop/mamori ``` Requires Go 1.26 or newer. Each backend (AWS, Vault, GCP, ...) is a separate module you add only if you use it: ```bash go get github.com/xavidop/mamori/providers/aws # aws-sm:// aws-ps:// ``` ## 2. Describe your config Tag each field with a `source:` ref. Use `secret.String` for secret values so they redact everywhere except an explicit `Reveal()`, and add `validate:` rules to reject bad config at load time: ```go package main import ( "context" "log" "github.com/xavidop/mamori" "github.com/xavidop/mamori/secret" ) type Config struct { LogLevel string `source:"env:LOG_LEVEL" default:"info" validate:"oneof=debug info warn error"` Workers int `source:"env:WORKERS" default:"4" validate:"gte=1,lte=256"` DBURL secret.String `source:"env:DATABASE_URL" validate:"required"` } ``` ## 3. Load it once `Load` resolves every field, validates the struct, and returns it. If anything fails to resolve or validate, it returns an error and no partial struct: ```go func main() { cfg, err := mamori.Load[Config](context.Background()) if err != nil { log.Fatal(err) // e.g. DATABASE_URL missing, or WORKERS out of range } log.Printf("level=%s workers=%d db=%s", cfg.LogLevel, cfg.Workers, cfg.DBURL) // db prints as [REDACTED]; cfg.DBURL.Reveal() returns the real value. } ``` Run it: ```bash LOG_LEVEL=debug WORKERS=8 DATABASE_URL=postgres://... go run . ``` ## 4. Add a backend provider Providers register with the `database/sql` blank-import pattern. Import one, then point a field at its scheme: ```go import _ "github.com/xavidop/mamori/providers/aws" // registers aws-sm:// and aws-ps:// type Config struct { DBPassword secret.String `source:"aws-sm://prod/db#password"` } ``` See the [providers overview](/docs/providers/) for every scheme and how to authenticate it. ## 5. Watch for changes Swap `Load` for `Watch` to keep the struct reconciled while your program runs. `Get` always returns the latest fully-valid snapshot, and `OnChange` fires when a value changes: ```go w, err := mamori.Watch[Config](context.Background(), mamori.OnChange(func(ev mamori.Change[Config]) { if ev.Changed("Workers") { pool.Resize(ev.New.Workers) } }), ) if err != nil { log.Fatal(err) } defer w.Close() cfg := w.Get() // current snapshot, safe to call anytime ``` Rotate a secret in the backend and your process picks it up, with no restart and no half-applied config ever observed. ## A runnable example A complete program that loads from `env:` and `file://`, watches, and reacts to a live file rotation lives in [`examples/basic`](https://github.com/xavidop/mamori/tree/main/examples/basic): ```bash LOG_LEVEL=debug WORKERS=8 go run ./examples/basic ``` ## Where to go next - [Loading and watching](/docs/usage/) - `Load` vs `Watch`, change events, and error handling. - [Concepts](/docs/concepts/) - refs, the reconciler, source chains, and error kinds. - [Validation](/docs/validation/) - the full `validate:` rule set. - [Providers overview](/docs/providers/) - every backend and its watch strategy. - [Observability](/docs/observability/) - `Status`, `Health`, and the pre-deploy `Doctor` check. --- > Source: https://mamorigo.dev/docs/providers/onepassword # 1Password [1Password Connect](https://developer.1password.com/docs/connect/) over the REST API. Pure `net/http`, no third-party SDK. | | | | --- | --- | | Scheme | `op://` | | Module | `github.com/xavidop/mamori/providers/onepassword` | | Sensitive | yes | | Watch | poll | | Auth | `OP_CONNECT_HOST`, `OP_CONNECT_TOKEN` | ## Install ```bash go get github.com/xavidop/mamori/providers/onepassword ``` ```go import _ "github.com/xavidop/mamori/providers/onepassword" ``` ## Using the ref An `op://` ref points at one field of one item in a 1Password vault. This matches the familiar 1Password secret-reference format. ```text op://// ``` | Part | Required | What it means | | --- | --- | --- | | `` | yes | Vault name or id. A name is looked up first, then falls back to being treated as an id. | | `` | yes | Item title or id within that vault. | | `` | yes | Field label or id on that item. | **Examples** - `op://Production/postgres/password` reads the `password` field of the `postgres` item in the `Production` vault. - `op://Production/stripe/api_key` reads the `api_key` field of the `stripe` item. ```go type Config struct { DBPassword secret.String `source:"op://Production/postgres/password"` APIKey secret.String `source:"op://Production/stripe/api_key"` } ``` Values are marked `Sensitive`, and `Value.Version` is the item version (or a content hash when the item has no version). ## Explicit configuration ```go import opprov "github.com/xavidop/mamori/providers/onepassword" mamori.WithProvider(opprov.New( opprov.WithHost("https://connect.internal:8080"), opprov.WithToken(os.Getenv("OP_CONNECT_TOKEN")), )) ``` `Close()` is idempotent and terminal: after it returns, every `Resolve` reports `errors.Is(err, mamori.ErrUnavailable)` locally, without contacting Connect. It also returns its own idle HTTP connections to the pool, and leaves connections belonging to the rest of your process alone. A client injected with `WithHTTPClient` is never closed, so it stays usable for whatever else holds it. ## Watch 1Password Connect has no push channel, so mamori polls (`WithPollInterval` + jitter). ## Error classification | HTTP status | mamori kind | |---|---| | 404 | `not_found` | | 403 | `permission_denied` | | 401 | `unauthenticated` | | 429 | `rate_limited` | | 5xx | `unavailable` | | 400 | `invalid` | | anything else | `unknown` | Connect's error responses carry only a numeric status and a free-text message, no machine-readable error code, so classification is by status alone. A missing vault, item, or field is reported directly as `not_found` with its own message rather than through this table. Verified by unit tests and the conformance kit against an in-process HTTP fake of the Connect API (injected `*http.Client`). Live behavior against a running Connect server is covered by `//go:build integration` tests. --- > Source: https://mamorigo.dev/docs/observability/admin # Admin endpoint Serve a watcher's [`Report`](/docs/observability/#report-and-fieldstatus) over HTTP two ways: mount `Handler` on a mux you already run, or let mamori run its own server with `WithAdminHTTP`. Both expose exactly the same two routes. | Route | Response | | --- | --- | | `GET /` | The `Report`, as JSON (same shape as `w.Status()`) | | `GET /healthz` | A liveness/readiness signal, `{"status":"ok"}` or `{"status":"unhealthy",...}` | Every other path, and every other method, is `404`. ## Metadata only, never a value **This is a metadata endpoint. It never serves a configuration value, under any option, on any route.** The JSON body is always `w.Status()`, whose `Ref` fields are already redacted and which never carries a resolved value. `Watcher.Pin` / `Watcher.PinCurrent` / `Watcher.Unpin` are not reachable through either route: `GET /` reports `Pinned` (and the `Snapshot`/`Live` divergence it causes) read-only, but nothing here changes it. For the surface that serves resolved config *values* to many callers, see the [config server](/docs/server/). **`WithRefVars` values must not be secrets.** After [`${VAR}` interpolation](/docs/concepts/ref-interpolation/) expands a ref, that ref's `Raw` holds the expanded string - and this endpoint's `Report` is exactly where it becomes visible, alongside `Status()` and `mamori doctor` output. Variables are for environment names, regions, service names, and tenant identifiers, not for anything that itself needs to stay confidential. ## Telling a live pod from one that booted on a snapshot With the [bootstrap cache](/docs/usage/bootstrap-cache/) configured, `GET /` answers the question you actually have during an incident: is this pod serving what its backends said, or what a file on its disk held? ```json { "Source": "bootstrap_cache", "Bootstrap": { "Present": true, "Restored": true, "WrittenAt": "2026-08-04T17:47:40Z", "Age": 7200000000000, "FingerprintMatch": true, "Problem": "" }, "Healthy": true, "Snapshot": 1, "Live": 1 } ``` **Alert on `Source`.** It reads `bootstrap_cache` only while the snapshot is still covering for at least one field, and returns to `backend` on its own once every field has been resolved live, so the alert clears when the outage does. **Check `Restored` afterwards.** It stays `true` for the whole life of a pod that booted from disk, which is how you tell an hour later that this pod restarted *during* the outage rather than after it. `Age` is the snapshot's age in nanoseconds and `WrittenAt` the instant it was written, so "how old is the config this pod is serving" is one field. `Problem` names the reason when a snapshot exists but could not be used, a wrong key being the common one, and `FingerprintMatch` is `false` when the snapshot was written by a build whose config struct no longer matches this one. `Healthy` describes fields, not the snapshot, so it stays `true` while serving a restored config. That is deliberate: the pod joins the load balancer instead of the outage becoming total. What drops it out once the snapshot passes `BootstrapMaxAge` is `GET /healthz`, which returns `503` for a config frozen longer than you allowed. On a process that does not configure the cache both keys are absent from the body entirely, so anything already parsing this endpoint sees exactly what it saw before. ## Mount Handler on your own mux ```go func Handler[T any](w *Watcher[T], opts ...HandlerOption) http.Handler ``` ```go w, err := mamori.Watch[Config](ctx) if err != nil { log.Fatal(err) } defer w.Close() mux := http.NewServeMux() mux.Handle("/", mamori.Handler(w)) go http.ListenAndServe(":8080", mux) ``` Mount it under a subpath with `HandlerPrefix`, which strips the prefix before the request reaches mamori's own routing: ```go mux.Handle("/admin/", mamori.Handler(w, mamori.HandlerPrefix("/admin"))) ``` `HandlerMiddleware` wraps the handler with a non-authentication concern such as request logging. It runs outside `HandlerPrefix`'s stripping and outside any `WithAuth` check, in the order the options are given. Authentication itself, `WithAuth` and the shipped schemes, is covered on the [Auth](/docs/auth/) page. ## No `POST /refresh` There is no route here that triggers a reload, and there will not be one. `GET /` and `GET /healthz` are the whole surface - every other path and method is `404` - and both only ever read `w.Status()`, never write anything. That is a deliberate security property, not a missing feature: this endpoint exists to report on a watcher that already handles secret material, and a mutating route on it would let anyone who can merely *observe* that material also *trigger* a fresh resolve of it, on demand. Read access and reload access are different privileges, and this surface only ever grants the first. If you want an HTTP-triggered refresh, mount one yourself on the same `mux`, gated by whatever authorization you already trust for an administrative action - it does not have to be, and generally should not be, the same `Authenticator` guarding the read-only `Report` above: ```go mux.HandleFunc("/refresh", func(rw http.ResponseWriter, r *http.Request) { if !authorizedForReload(r) { // your own check, not mamori's http.Error(rw, "forbidden", http.StatusForbidden) return } if err := w.Refresh(r.Context()); err != nil { http.Error(rw, err.Error(), http.StatusConflict) return } rw.WriteHeader(http.StatusNoContent) }) ``` `w.Refresh` itself - what it does, why it blocks, and what it returns - is covered in [Rotation safety](/docs/usage/refresh/). ## Run a standalone server with WithAdminHTTP ```go func WithAdminHTTP(addr string, opts ...HandlerOption) Option func WithAdminTLS(cfg *tls.Config) Option ``` ```go w, err := mamori.Watch[Config](ctx, mamori.WithAdminHTTP("127.0.0.1:9090"), ) if err != nil { log.Fatal(err) // includes a bind failure } defer w.Close() log.Printf("admin endpoint listening on %s", w.AdminAddr()) ``` `WithAdminHTTP` is for a caller who does not already run a mux of their own. It carries the same fail-fast lifecycle guarantees as the rest of mamori: - **Off by default.** With no `WithAdminHTTP` option, no listener is bound and no goroutine starts. - **A bind failure fails `Watch`.** The listener is bound before `Watch` returns, so a port already in use, or a permission error, comes back as `Watch`'s own error. - **`Close` releases the port.** `Watcher.Close` shuts the admin server down gracefully, bounded by a short grace period, before it returns. - **`AdminAddr()` gives you the bound address** (`func (w *Watcher[T]) AdminAddr() net.Addr`), which is `nil` unless `WithAdminHTTP` was used. This is how you discover the port the OS actually chose when binding to `:0`. - **`WithAdminTLS(cfg)` serves the endpoint over TLS** instead of plaintext, and has no effect without `WithAdminHTTP`. Pair it with an `Authenticator` (see [Auth](/docs/auth/)) so a credential sent to the endpoint is never sent in the clear. - `Load` accepts `WithAdminHTTP` too, since `Load` and `Watch` share the same `Option` type, but `Load` has no long-lived watcher to run a server against, so it silently ignores the option. ## Wire a readiness probe `GET /healthz` is built to back a Kubernetes readiness probe directly. Start the admin endpoint and point the probe at it: ```go w, err := mamori.Watch[Config](ctx, mamori.WithAdminHTTP(":9090")) ``` ```yaml readinessProbe: httpGet: path: /healthz port: 9090 periodSeconds: 5 ``` An unauthenticated caller, such as a kubelet probe, always gets a bare status (`200 {"status":"ok"}` or `503 {"status":"unhealthy"}`), so readiness never depends on holding a credential, even when the endpoint has [`WithAuth`](/docs/auth/) configured. `/healthz` never returns `401`. If auth is configured and the caller authenticates (or no auth is configured at all), the body also includes the failing-field detail (the same fields a `*HealthError` carries). The response body is always metadata, never a config value. ## See also - [Observability overview](/docs/observability/) - `Status`, `Health`, and the `Report` shape. - [Doctor](/docs/observability/doctor/) - the pre-deploy counterpart to these live endpoints. - [Rotation safety](/docs/usage/rotation/) - `PreApply` and `w.Refresh`, which a hand-rolled `/refresh` route above would call. - [Config server](/docs/server/) - serves resolved config *values*, not metadata. - [Auth](/docs/auth/) - `WithAuth`, the shipped schemes, and credential rotation. - [Bootstrap cache](/docs/usage/bootstrap-cache/) - the option behind the `Source` and `Bootstrap` fields above. --- > Source: https://mamorigo.dev/docs/skill # Agent skill mamori ships an [Agent Skill](https://www.skills.sh/) that teaches an AI coding agent (Claude Code, Cursor, Copilot, Windsurf, Gemini, and others) how to use mamori: defining config structs with `source:` tags, picking and wiring providers, keeping secrets in `secret.String`, watching for live changes, and driving the `mamori` CLI. The skill lives in this repository under [`skills/mamori/`](https://github.com/xavidop/mamori/tree/main/skills/mamori). ## Install The one-line install works for any agent skills.sh supports: ```bash npx skills add xavidop/mamori ``` This fetches the skill and drops it into your agent's skills directory. Your agent then loads it automatically when a task involves loading config or secrets, wiring a provider, or the mamori CLI. ## Install manually If you would rather not use the CLI, copy the skill folder into your agent's skills directory yourself. For Claude Code that is `~/.claude/skills/`: ```bash git clone https://github.com/xavidop/mamori cp -r mamori/skills/mamori ~/.claude/skills/mamori ``` Other agents use their own location (for example a project-level `.cursor/` or `.github/` skills folder); see your agent's documentation. ## What it covers - The model: `source:` tags, providers as blank imports, `Load` versus `Watch`, and `secret.String` redaction. - Defining and loading a config struct, with validation. - Watching for live changes and reacting per field. - Choosing a provider, with a full scheme cheat-sheet in the skill's `references/providers.md`. - The `mamori` CLI (`explain`, `schema`, `policy`, `vet`, `doctor`, `status`) and its exit codes. ## llms.txt If your agent does not use skills, point it at the documentation directly. This site publishes the [llms.txt convention](https://llmstxt.org/): - [`/llms.txt`](https://mamorigo.dev/llms.txt) - a short index of the docs with links, for an agent to navigate. - [`/llms-full.txt`](https://mamorigo.dev/llms-full.txt) - the entire documentation as one Markdown file, for an agent to load in a single fetch. A prompt that works in most coding agents: ```text Add mamori to my Go project. Docs: https://mamorigo.dev/llms.txt ``` ## See also - [Quick start](/docs/quickstart/) - the same ground for a human reader. - [Providers overview](/docs/providers/) - every scheme and how to authenticate it. - [CLI](/docs/cli/) - the command reference the skill points agents at. --- > Source: https://mamorigo.dev/docs/providers/s3 # Amazon S3 Fetch a config or secret blob from an S3 bucket (or any S3-compatible store: MinIO, Cloudflare R2). | | | | --- | --- | | Scheme | `s3://` | | Module | `github.com/xavidop/mamori/providers/s3` | | Sensitive | no (opt-in with `WithSensitive`) | | Watch | poll (ETag) | | Auth | default AWS credential chain (`WithRegion`) | ## Install ```bash go get github.com/xavidop/mamori/providers/s3 ``` ```go import _ "github.com/xavidop/mamori/providers/s3" ``` ## Using the ref An `s3://` ref points at one object in a bucket, fetched with a single `GetObject`. ```text s3:///[#json-key] ``` | Part | Required | What it means | | --- | --- | --- | | `` | yes | The S3 bucket name. | | `` | yes | The object key. It may contain slashes - everything after the bucket segment is the key. | | `#json-key` | no | Treat the object as a JSON object and return one field of it. | **Examples** - `s3://my-bucket/config/app.json` fetches the whole object - decode it with `flatten:"json"`. - `s3://my-bucket/config/app.json#database` returns just the `database` field of that JSON object. - `s3://my-bucket/tls/app.crt` fetches a raw blob (a certificate) - pair it with a `[]byte` field. ```go type Config struct { AppConfig AppConfig `source:"s3://my-bucket/config/app.json" flatten:"json"` Cert []byte `source:"s3://my-bucket/tls/app.crt"` } ``` The object key may contain slashes, so `config/prod/app.json` is a single key. `Value.Version` is the object ETag (or version id), so change detection is cheap: mamori compares the ETag before downloading again. Objects are not marked sensitive by default; because buckets often hold secret bundles (credential JSON, PEM chains, dotenv files), pass `WithSensitive(true)` to redact resolved values downstream. ## Watch mamori polls (`WithPollInterval` + jitter) using the ETag. For push, wire S3 Event Notifications to SQS/EventBridge and reload on demand. ## Error classification Failures are classified so `mamori.ErrorKind` can distinguish them: | S3 error code | mamori kind | |---|---| | `NoSuchKey`, `NoSuchBucket`, `NoSuchVersion`, `NotFound` | `not_found` | | `AccessDenied`, `AllAccessDisabled` | `permission_denied` | | `InvalidAccessKeyId`, `SignatureDoesNotMatch`, `ExpiredToken`, `InvalidToken`, `TokenRefreshRequired` | `unauthenticated` | | `SlowDown` | `rate_limited` | | `ServiceUnavailable`, `InternalError` | `unavailable` | | `InvalidRequest`, `InvalidArgument`, `MalformedXML` | `invalid` | | anything else | `unknown` | Codes not listed above report `unknown` rather than being guessed at. `SlowDown` and `ServiceUnavailable` share a 503 status but mean different things (throttling vs. overload), so they map to different kinds. `NoSuchVersion` is defensive: `Resolve` never sets `GetObjectInput.VersionId`, so this provider cannot actually trigger it today; it is included in case a future code path requests a specific object version. The original SDK error stays reachable with `errors.As`. ## Configuration ```go import s3prov "github.com/xavidop/mamori/providers/s3" mamori.WithProvider(s3prov.New(s3prov.WithRegion("eu-west-1"))) // S3-compatible (MinIO / R2): mamori.WithProvider(s3prov.New(s3prov.WithEndpoint("https://.r2.cloudflarestorage.com"))) ``` Verified with an in-memory fake; live behavior is covered by `//go:build integration` tests. --- > Source: https://mamorigo.dev/docs/auth # Auth An `Authenticator` decides whether an HTTP request may proceed and says who the caller is. One interface serves both mamori surfaces, the admin HTTP endpoint (`Handler`, `WithAdminHTTP`) and the [config server](/docs/server/), so a scheme configured for one works unchanged on the other. ## Quick start The admin endpoint has no `Authenticator` by default: any request that can reach it gets the `Report`. `WithAuth` attaches one. This wires a shared bearer token onto mamori's self-hosted admin server: ```go auth := mamori.BearerToken(secret.NewString(os.Getenv("ADMIN_TOKEN"))) w, err := mamori.Watch[Config](ctx, mamori.WithAdminHTTP("127.0.0.1:9090", mamori.WithAuth(auth)), ) ``` The same `auth` value drops onto your own mux, or onto the config server, unchanged: ```go mux.Handle("/", mamori.Handler(w, mamori.WithAuth(auth))) ``` ## The Authenticator interface ```go type Authenticator interface { Authenticate(r *http.Request) (Identity, error) } ``` A `nil` error allows the request; any other error denies it. On success `Authenticate` returns an `Identity`: ```go type Identity struct { Subject string Attrs map[string][]string } ``` `Subject` is a stable principal name; `Attrs` carries scheme-specific detail (certificate SANs, token claims, a peer uid/gid/pid) and is multi-valued so a scheme can return groups, scopes, or multiple SANs directly. The admin endpoint ignores the `Identity` (it only serves metadata); the [config server](/docs/server/) consumes it, since its `Policy` decides what a caller may see based on it. Two optional pieces complete the interface: - `Challenger` supplies the `WWW-Authenticate` header on a `401`; a scheme that does not implement it produces a bare `401`. - `ErrForbidden` selects the status code: return it from `Authenticate` to produce a `403` (authenticated but not permitted) rather than a `401`. ```go type Challenger interface { Challenge() string } var ErrForbidden = errors.New("mamori: forbidden") ``` ## Wiring it up `WithAuth` is a `HandlerOption`, so it goes wherever `HandlerOption`s go: as an argument to `Handler`, or after the address in `WithAdminHTTP`. ```go auth := mamori.BearerToken(secret.NewString(os.Getenv("ADMIN_TOKEN"))) // Mounted on your own mux: mux.Handle("/", mamori.Handler(w, mamori.WithAuth(auth))) // Or with mamori's self-hosted server: w, err := mamori.Watch[Config](ctx, mamori.WithAdminHTTP("127.0.0.1:9090", mamori.WithAuth(auth)), ) ``` Applying `WithAuth` more than once panics rather than silently letting the second call win; compose multiple schemes explicitly with `AnyOf` or `AllOf` instead. ## Next - [Auth schemes](/docs/auth/schemes/): BasicAuth, BearerToken, APIKey, MTLS, PeerCred, JWT, AnyOf/AllOf. - [Custom authenticators](/docs/auth/custom/): the `Func` variants, credential rotation, and writing your own. - [HTTP exposure](/docs/observability/admin/) covers the admin endpoint the auth attaches to. - [Config server](/docs/server/) is the second surface, where `Policy` authorizes against the `Identity`. --- > Source: https://mamorigo.dev/docs/auth/schemes # Auth schemes The schemes mamori ships. Each is a `mamori.Authenticator`, so it works unchanged on the admin endpoint and the [config server](/docs/server/). Attach any of them with `WithAuth` (see the [overview](/docs/auth/)). For rotation and writing your own, see [Custom authenticators](/docs/auth/custom/). ## BasicAuth ```go func BasicAuth(user string, pass secret.String) Authenticator ``` ```go auth := mamori.BasicAuth("admin", secret.NewString("hunter2")) ``` Checks HTTP Basic credentials against a fixed user and password. Both the username and the password are compared in constant time. `pass` is a `secret.String`, so it redacts in logs and error values. Implements `Challenger` (`Basic realm="mamori"`). ## BearerToken ```go func BearerToken(token secret.String) Authenticator ``` ```go auth := mamori.BearerToken(secret.NewString(os.Getenv("ADMIN_TOKEN"))) ``` Checks `Authorization: Bearer ` against a fixed token, compared in constant time. The `Bearer ` prefix itself is checked with `strings.HasPrefix`, since it is a fixed, public protocol string. Implements `Challenger` (`Bearer`). ## APIKey ```go func APIKey(header string, key secret.String) Authenticator ``` ```go auth := mamori.APIKey("X-API-Key", secret.NewString(os.Getenv("ADMIN_KEY"))) ``` Checks a named header against a fixed key, compared in constant time. Implements no `Challenger`: an API key is not a scheme a generic HTTP client knows how to answer a `WWW-Authenticate` challenge for, so a failed request gets a bare `401`. ## MTLS ```go func MTLS(opts MTLSOptions) Authenticator type MTLSOptions struct { AllowedCNs []string AllowedDNSNames []string } ``` ```go auth := mamori.MTLS(mamori.MTLSOptions{ AllowedCNs: []string{"admin-client"}, }) ``` Authenticates by the client's already-verified TLS certificate. It requires the server be configured with `tls.RequireAndVerifyClientCert` (via `WithAdminTLS`); `MTLS` only checks which verified identity is allowed, not whether the chain is trustworthy (the Go TLS stack has already done that). `AllowedCNs`/`AllowedDNSNames` are optional allowlists checked against the leaf certificate's `CommonName` and DNS SANs; if both are empty, any verified certificate is accepted. On a non-TLS connection, or a TLS connection with no client certificate, `MTLS` denies every request. ## PeerCred ```go func PeerCred(opts PeerCredOptions) Authenticator type PeerCredOptions struct { UIDs []int GIDs []int } ``` ```go auth := mamori.PeerCred(mamori.PeerCredOptions{ UIDs: []int{1000, 1001}, }) ``` Authenticates a Unix-domain-socket peer by the uid/gid the kernel reports at accept time (`SO_PEERCRED` on Linux, `LOCAL_PEERCRED` via `GetsockoptXucred` on Darwin), never anything the client presents. Because the identity comes from the kernel, it cannot be spoofed by a client that can merely connect to the socket. `UIDs`/`GIDs` are optional allowlists, ORed together (a peer is permitted if its uid is in `UIDs` or its gid is in `GIDs`). If both are empty, any peer whose credentials were read is permitted. On success, `Identity.Subject` is `"uid:"` and `Attrs` carries `"uid"`, `"gid"`, and `"pid"` (Darwin's `Xucred` carries no pid, so `Attrs["pid"]` is always `["0"]` there). `PeerCred` requires the [config server](/docs/server/)'s `Unix(...)` transport. `WithAdminHTTP` does not support it: it only listens on TCP, where there is no Unix-socket peer to read credentials from. It also denies outright when no peer credentials are available, and on any platform other than Linux or Darwin. ## JWT (x/authjwt) JWT support ships as a separate module, `github.com/xavidop/mamori/x/authjwt`, since it depends on `github.com/golang-jwt/jwt/v5` and core takes no non-stdlib dependencies. The returned value is a `mamori.Authenticator` like any other. ```go import "github.com/xavidop/mamori/x/authjwt" auth, err := authjwt.New(authjwt.Config{ Key: authjwt.HMAC(secretBytes), Issuer: "https://issuer.example.com/", Audiences: []string{"mamori-admin"}, Claims: []string{"groups", "scope"}, }) if err != nil { log.Fatal(err) } w, err := mamori.Watch[Config](ctx, mamori.WithAdminHTTP("127.0.0.1:9090", mamori.WithAuth(auth)), ) ``` ```sh go get github.com/xavidop/mamori/x/authjwt ``` Key material comes from exactly one of `Key` or `Keyfunc`. `Key` is the normal path: each helper supplies both the key and the algorithms it is valid for, so the two can never drift apart. ```go func HMAC(secret []byte) KeyOption // HS256, HS384, HS512 func RSAPublicKey(key *rsa.PublicKey) KeyOption // RS256/384/512, PS256/384/512 func ECDSAPublicKey(key *ecdsa.PublicKey) KeyOption // ES256, ES384, ES512 func EdDSAPublicKey(key ed25519.PublicKey) KeyOption // EdDSA ``` `Keyfunc` is the escape hatch (most commonly a JWKS endpoint that picks a key by the token's `kid`). Because a raw `Keyfunc` can return key material for any algorithm, `Algorithms` must also be set explicitly; leaving it empty is a `Config` error, not a permissive default. `Issuer` (when set) must match the token's `iss` exactly; `Audiences` (when non-empty) requires `aud` to contain at least one listed value. `SubjectClaim` (default `"sub"`) names the claim copied into `Identity.Subject`. `Claims` names claims copied into `Identity.Attrs`; the `scope`/`scp` claims are split on spaces even when string-valued. `Realm`, if set, appears in the challenge. Security posture, enforced on every request: parsing is restricted with `jwt.WithValidMethods` to exactly the algorithms implied by the key (so `alg: none` and the RSA/HMAC key-confusion attack are rejected); expiration is mandatory (`jwt.WithExpirationRequired` rejects an expired token and one with no `exp`); issuer/audience are validated when configured; and the token is read only from the `Authorization` header with a case-insensitive `Bearer ` prefix. A missing, malformed, expired, or invalid token is a `401`, never `ErrForbidden`. The authenticator implements `Challenger`. ## AnyOf and AllOf ```go func AnyOf(as ...Authenticator) Authenticator func AllOf(as ...Authenticator) Authenticator ``` `AnyOf` allows a request if any member allows it, for example a static admin token or mTLS from a mesh sidecar: ```go auth := mamori.AnyOf( mamori.BearerToken(secret.NewString(os.Getenv("ADMIN_TOKEN"))), mamori.MTLS(mamori.MTLSOptions{}), ) ``` Every member is evaluated on every request, even after one succeeds or fails, so total work never depends on which member matched (no timing oracle). If any member implements `Challenger`, the first such member in argument order determines `AnyOf`'s challenge. `AllOf` allows a request only if every member allows it, for example a bearer token and an mTLS-verified identity: ```go auth := mamori.AllOf( mamori.BearerToken(secret.NewString(os.Getenv("ADMIN_TOKEN"))), mamori.MTLS(mamori.MTLSOptions{AllowedCNs: []string{"mesh-sidecar"}}), ) ``` The first denial fails the whole check and later members are skipped. The `Identity` of the first member is returned; by convention that first member is the primary authenticator and later members perform supplementary checks. --- > Source: https://mamorigo.dev/docs/providers/aws-appconfig # AWS AppConfig AWS AppConfig, built on `aws-sdk-go-v2`'s `appconfigdata` client. Ships in the same module as [AWS Secrets Manager and SSM Parameter Store](/docs/providers/aws). | | | | --- | --- | | Schemes | `aws-appconfig://` | | Module | `github.com/xavidop/mamori/providers/aws` | | Sensitive | no | | Watch | poll | | Auth | default AWS credential chain (`AWS_REGION`, env, shared config, IAM role) | ## Install ```bash go get github.com/xavidop/mamori/providers/aws ``` ```go import _ "github.com/xavidop/mamori/providers/aws" // registers aws-sm://, aws-ps://, and aws-appconfig:// ``` ## Using the ref An `aws-appconfig://` ref points at one configuration profile in one AWS AppConfig environment. ```text aws-appconfig:////[#json-key][?minPoll=] ``` | Part | Required | What it means | | --- | --- | --- | | `` | yes | The AppConfig application ID or name. | | `` | yes | The AppConfig environment ID or name. | | `` | yes | The configuration profile ID or name. | | `#json-key` | no | Select one field from a JSON configuration payload (via `mamori.SelectKey`). | | `?minPoll=` | no | Sets `RequiredMinimumPollIntervalInSeconds` on the session. Inert today: the floor constrains a session's second and later calls, and every session here is discarded after its first. | Each of the three path segments may be either the resource's AWS-assigned ID or its name; the provider passes them through verbatim and lets AppConfig Data resolve them. **Examples** - `aws-appconfig://myapp/prod/flags` returns the whole configuration payload for the `flags` profile. - `aws-appconfig://myapp/prod/flags#/db/port` selects the `port` field nested under `db` in a JSON configuration. ```go type Config struct { Flags string `source:"aws-appconfig://myapp/prod/flags"` // whole configuration payload Port int `source:"aws-appconfig://myapp/prod/flags#/db/port"` // one field of a JSON configuration } ``` AppConfig values are never marked `Sensitive`: AppConfig is a configuration service, not a secret store, so nothing about its payloads warrants secret-hygiene treatment by default. Store secrets in Secrets Manager or Parameter Store `SecureString` and reference them from your AppConfig-managed configuration instead. `Value.Version` is the configuration profile's `VersionLabel` when the source is an AppConfig-hosted configuration version, falling back to `mamori.VersionHash` for every other configuration source (Parameter Store, SSM documents, Secrets Manager, S3, or Feature Flags), which have no such label. ### Why `Resolve` costs two API calls AppConfig Data is a session protocol, not a plain request/response API: a caller first opens a configuration session with `StartConfigurationSession`, then polls it with `GetLatestConfiguration`. A session that already holds the current version receives an *empty* payload from `GetLatestConfiguration` - that's how the protocol tells a long-lived poller "nothing changed." A provider that opened one session and reused it across `Resolve` calls would therefore return the configuration on the first call and empty bytes on every call after, and mamori would apply those empty bytes over a live configuration field - a silent, hard-to-notice config wipe. `Resolve` avoids this entirely by starting a fresh session and discarding it on every call: a session created moments ago holds no version at all, so the empty-payload case can never legitimately occur on this path. The cost is one extra API call per `Resolve`, which is the price of a stateless, always-correct `Resolve`. ## Explicit configuration ```go import awsprov "github.com/xavidop/mamori/providers/aws" mamori.WithProvider(awsprov.NewAppConfig(awsprov.WithRegion("eu-west-1"))) ``` ## Watch AppConfig has no push mechanism, so mamori polls this provider (`WithPollInterval` + jitter, `Value.Version` comparison). A session-based `WatchableProvider` was considered and deliberately rejected. AppConfig's session protocol is still polling, just polling that remembers what it last saw, and mamori's rule for provider authors is that a backend without native change notification must be left to the polling adapter rather than given an internal ticker. That adapter is where jitter, change deduplication, and the injectable clock live. Jitter is the one that matters here: AppConfig hands every session the same cadence, so replicas started together would poll in lockstep against an API AWS prices per call. Each poll costs two API calls, `StartConfigurationSession` followed by `GetLatestConfiguration`, for the reason described in [Why `Resolve` costs two API calls](#why-resolve-costs-two-api-calls). Set `WithPollInterval` accordingly. ## Error classification Failures are classified so `mamori.ErrorKind` can distinguish them: | AWS error code | mamori kind | |---|---| | `ResourceNotFoundException`, `ParameterNotFound`, `ParameterVersionNotFound` | `not_found` | | `AccessDeniedException` | `permission_denied` | | `UnrecognizedClientException`, `ExpiredTokenException`, `InvalidSignatureException`, `MissingAuthenticationToken`, `IncompleteSignature` | `unauthenticated` | | `ThrottlingException`, `Throttling`, `TooManyRequestsException`, `RequestLimitExceeded` | `rate_limited` | | `InternalServiceError`, `InternalServerError`, `InternalFailure`, `InternalServerException`, `ServiceUnavailable`, `ServiceUnavailableException` | `unavailable` | | `InvalidParameterException`, `InvalidRequestException`, `ValidationException`, `InvalidParameterValue`, `InvalidKeyId`, `BadRequestException` | `invalid` | | anything else | `unknown` | `InternalServerException` and `BadRequestException` are AppConfig Data's own error codes: AppConfig spells its server error differently from the `InternalServerError` the other two schemes in this module use, and `BadRequestException` is what a reused or expired configuration session token comes back as. A missing application, environment, or profile is reported as `ResourceNotFoundException` at `StartConfigurationSession` time, since AppConfig Data resolves identifiers at session start rather than at fetch time. Codes not listed above report `unknown` rather than being guessed at. The original SDK error stays reachable with `errors.As`. Verified by unit tests against an in-memory fake that models the AppConfig Data session protocol (single-use tokens, rejection of reused tokens, empty payload on an unchanged version), and the `providertest` conformance kit against the same fake. --- > Source: https://mamorigo.dev/docs/providers/aws # AWS Secrets Manager and SSM Parameter Store, built on `aws-sdk-go-v2`. | | | | --- | --- | | Schemes | `aws-sm://` `aws-ps://` | | Module | `github.com/xavidop/mamori/providers/aws` | | Sensitive | Secrets Manager: yes · Parameter Store: SecureString only | | Watch | poll | | Auth | default AWS credential chain (`AWS_REGION`, env, shared config, IAM role) | ## Install ```bash go get github.com/xavidop/mamori/providers/aws ``` ```go import _ "github.com/xavidop/mamori/providers/aws" // registers aws-sm:// and aws-ps:// ``` ## Using the ref An `aws-sm://` ref points at one secret in AWS Secrets Manager; an `aws-ps://` ref points at one parameter in SSM Parameter Store. ```text aws-sm://[#json-key] aws-ps://[#json-key] ``` | Part | Required | What it means | | --- | --- | --- | | `` | yes | The Secrets Manager secret name or ARN. | | `` | yes | The Parameter Store name, including its leading slash, e.g. `/myapp/log-level`. | | `#json-key` | no | Select one field from a JSON secret/parameter payload (via `mamori.SelectKey`). | **Examples** - `aws-sm://prod/api-key` returns the whole secret string - use it for an opaque token. - `aws-sm://prod/db#password` returns just the `password` field of a JSON secret. - `aws-ps:///myapp/log-level` reads the `/myapp/log-level` parameter (note the extra slash: the `aws-ps://` scheme plus the `/myapp/...` name). - `aws-ps:///myapp/db#password` selects `password` from a JSON parameter. ```go type Config struct { APIKey secret.String `source:"aws-sm://prod/api-key"` // whole secret string DBPassword secret.String `source:"aws-sm://prod/db#password"` // one key of a JSON secret LogLevel string `source:"aws-ps:///myapp/log-level"` // SecureString is marked sensitive } ``` Secrets Manager values are always `Sensitive`; Parameter Store reads with `WithDecryption=true` and marks only `SecureString` parameters `Sensitive`. `Value.Version` is the secret's `VersionId` or the parameter's numeric `Version`. Secrets Manager implements `BatchProvider`, so multiple `aws-sm://` refs resolve in one `BatchGetSecretValue` call. ## Explicit configuration ```go import awsprov "github.com/xavidop/mamori/providers/aws" mamori.WithProvider(awsprov.NewSecretsManager(awsprov.WithRegion("eu-west-1"))) mamori.WithProvider(awsprov.NewParameterStore(awsprov.WithRegion("eu-west-1"))) ``` ## Watch Neither backend has native change notification, so mamori polls (`WithPollInterval` + jitter, `Value.Version` comparison). For push-based rotation you can pair this with an EventBridge -> SQS trigger in your app and call `Load` on demand. ## Error classification Failures are classified so `mamori.ErrorKind` can distinguish them: | AWS error code | mamori kind | |---|---| | `ResourceNotFoundException`, `ParameterNotFound`, `ParameterVersionNotFound` | `not_found` | | `AccessDeniedException` | `permission_denied` | | `UnrecognizedClientException`, `ExpiredTokenException`, `InvalidSignatureException`, `MissingAuthenticationToken`, `IncompleteSignature` | `unauthenticated` | | `ThrottlingException`, `Throttling`, `TooManyRequestsException`, `RequestLimitExceeded` | `rate_limited` | | `InternalServiceError`, `InternalServerError`, `InternalFailure`, `InternalServerException`, `ServiceUnavailable`, `ServiceUnavailableException` | `unavailable` | | `InvalidParameterException`, `InvalidRequestException`, `ValidationException`, `InvalidParameterValue`, `InvalidKeyId`, `BadRequestException` | `invalid` | | anything else | `unknown` | This table is the whole of `classifyAWS`, one function shared by all three schemes in this module - `aws-sm://`, `aws-ps://`, and [`aws-appconfig://`](/docs/providers/aws-appconfig) - so it lists every code any of the three can produce, not only the ones Secrets Manager and Parameter Store return. `InternalServerException` and `BadRequestException` are AppConfig Data's codes; they are included here because a shared classifier means "anything else maps to unknown" has to hold for the whole module, not just for this page's two schemes. Codes not listed above report `unknown` rather than being guessed at. Notably, Secrets Manager's `DecryptionFailure` is deliberately left unmapped: it can mean a KMS key policy problem, a disabled key, or a KMS outage, and doesn't map cleanly to one kind. The original SDK error stays reachable with `errors.As`. Verified by unit tests and the `providertest` conformance kit against in-memory fakes; live AWS behavior is covered by `//go:build integration` tests. --- > Source: https://mamorigo.dev/docs/providers/azure-appconfig # Azure AppConfig Azure App Configuration, built on the `azappconfig` SDK. Ships in the same module as [Azure Key Vault](/docs/providers/azure). | | | | --- | --- | | Scheme | `azure-appconfig://` | | Module | `github.com/xavidop/mamori/providers/azure` | | Sensitive | no | | Watch | poll | | Auth | `DefaultAzureCredential` | ## Install ```bash go get github.com/xavidop/mamori/providers/azure ``` ```go import _ "github.com/xavidop/mamori/providers/azure" // registers azure-kv:// and azure-appconfig:// ``` ## Using the ref An `azure-appconfig://` ref points at one setting in an Azure App Configuration store, under a specific (or the null) label. ```text azure-appconfig:///[#json-key][?label=