An Eight Mile project · open source · MIT
vaultlet
A gRPC control plane for secrets you already store somewhere else. vaultlet does not hold a single secret: it sits in front of the Bitwarden Secrets Manager you already run and adds one access model, a policy that denies by default, and one audit log — over TLS and nothing else.
Runtime
Go 1.26 · gRPC
Backend
Bitwarden Secrets Manager
Transport
TLS 1.3 · Basic auth
Surface
5 RPCs · ~2,100 LOC
one request
interceptor chain
See it decide
Watch a request get checked.
Three scripted sessions, replayed exactly as the server handles them: a read that is allowed, audited and served; a key outside the policy that never reaches the backend; and a write against a backend that only reads.
vaultlet-cli
principal=ci-payments
$
One read: authenticated, checked against policy, served by Bitwarden, and audited.
What it solves
Six ways secrets access usually goes wrong.
Every one of these is a design decision in the code, not a setting you have to remember to switch on.
One machine token shared by every job.
A principal per caller, checked on every call.
Each CI job or operator authenticates as its own user, verified against a bcrypt hash on every RPC. The backend credential stays on the server and is never handed to a pipeline.
Access decided by whoever holds the backend token.
Policy that denies by default.
A rule names a namespace and the actions allowed under it, and a principal with no matching rule gets nothing. Rules are compiled at boot, so a typo fails the start rather than becoming a silent denial.
Who read what, scattered across vendor consoles.
One audit log, without the values.
Every get, put, list and delete writes one structured record: principal, action, key, decision and outcome. Denials are recorded too — a burst of them for one principal is the signal worth alerting on.
A plaintext port that was "just for dev".
TLS 1.3, or no connection at all.
The server has no plaintext mode to forget to turn off, and the client refuses to send a token over anything but TLS. Both sides pin the same floor.
A second vault to back up, rotate and lose.
Nothing stored, nothing to leak.
vaultlet holds no secrets. Values are created and edited in the Bitwarden Secrets Manager you already run, and every read goes to it. Take vaultlet away and nothing is lost.
A value printed by a log line nobody meant to write.
Values that cannot be logged by accident.
A secret is bytes, copied on the way in and on the way out, and its String method prints the key and the version only. Listings and events carry metadata and never the value.
By the numbers
as the repository states them
0
Secrets stored
every value lives in the backend
5
RPCs
one service, one proto file
1
Backend today
Bitwarden Secrets Manager
8
Namespace depth
segments, lowercase and hyphens
1.3
TLS floor
pinned on both sides of the dial
10 s
Graceful drain
then a forced stop, under SIGTERM
What you get
Built like infrastructure, not a wrapper.
The parts that matter in production — typed keys, honest versions, one audited path for values and a service that starts and stops cleanly — are in the repository, not on a roadmap.
Keys with a grammar
A key is namespace/name, validated once at the edge. The fields are unexported, so an invalid key cannot exist anywhere below the parser.
Opaque versions
A version compares for equality and nothing else. Bitwarden has none of its own, so the revision date is padded to fixed width and used as one.
Listings without values
List and watch return metadata only. Reading a rotated value is a separate, policy-checked, audited call — never a long-lived stream.
Status codes, not error fields
NOT_FOUND, PERMISSION_DENIED, FAILED_PRECONDITION: a caller reads the code and never learns which backend answered.
A CLI that is only a client
get, put, list, delete and watch over the same gRPC surface any client uses, with -o json as a stable contract for scripts. There is no back door.
Runs like a service should
Config from YAML, .env and environment in that order, validated before the backend opens. JSON logs to stderr. SIGTERM drains in-flight calls.
Layout
Ports and adapters, with one file that knows the difference.
The core has no idea gRPC, cobra or Bitwarden exist. Everything external is an adapter behind an interface, and cmd/vaultlet/main.go is the only place the concrete ones are named.
adapters/driving
·
grpcserver: handlers, auth and logging interceptors
·
cli: cobra commands, dial, render
·
proto ↔ domain mapping stops here
imports the core, never another adapter
domain · app · ports
·
domain: Key, Namespace, Secret, Version
·
app: Service, Policy, audit, principal
·
ports: SecretStore and its two errors
adapters/driven
·
bitwarden: SecretStore over the Rust SDK
·
aws: an empty directory, for now
·
vendor errors never leave the package
imports the core, never another adapter
Request path
Six stages, and the first one to object answers.
A unary RPC crosses two interceptors, the handler, the application service and the store, and the status code a caller sees names the stage that refused it. The audit record is written whichever way it went.
01
logging interceptor
Registered before authentication on purpose, so a rejected credential is still a logged RPC: method, duration in milliseconds and the status returned.
02
auth interceptor
Reads authorization: Basic from the metadata and compares the password against the user’s bcrypt hash. The principal lands in the context; nothing downstream can forge one.
03
handler
Parses the key or namespace once, at the edge. An expected_version on put or delete is refused outright rather than ignored, because a caller that believes it has compare-and-swap and does not is worse off.
04
app.Service
Checks the principal’s rules for the action at or above the namespace. A denial is audited and returned here; the store is never touched. Listings are filtered per key on the way back.
05
ports.SecretStore
The Bitwarden adapter resolves the name to a UUID, reads it, and wraps its two port errors so the handler can test with errors.Is. A read-only store refuses writes before calling out.
06
audit record
One structured record per service call — principal, action, key, decision, outcome — with no values, no credentials and no raw backend error. JSON to stderr by default.
A streaming RPC crosses the same two interceptors, with the principal carried on a wrapped stream. The chain is drawn again below, exactly as the hero draws it, because the two are one component.
Stage being checked
Refused with a status code
Read the source
Ten files that explain the whole thing.
Excerpts straight from the repository: the proto, the key grammar, the redacting secret, the port, the policy, the service, both interceptors, the one backend and the composition root.
vaultlet
api/proto/vaultlet/v1
internal/domain
internal/ports
internal/app
internal/adapters/driving/grpcserver
internal/adapters/driven/bitwarden
cmd/vaultlet
api/proto/vaultlet/v1/vaultlet.proto
service SecretService { // GetSecret returns the current revision of one secret, including its value. // This is the only RPC that transports secret bytes, which makes it the one // place where a per-read policy check and audit record have to happen. rpc GetSecret(GetSecretRequest) returns (GetSecretResponse); // PutSecret creates or replaces a secret and returns the revision it wrote. // Backends that do not accept writes fail with FAILED_PRECONDITION. rpc PutSecret(PutSecretRequest) returns (PutSecretResponse); // ListSecrets returns metadata — never values — for every secret at or // beneath a namespace. rpc ListSecrets(ListSecretsRequest) returns (ListSecretsResponse); // DeleteSecret removes a secret. Deleting a key that does not exist is // NOT_FOUND rather than a silent success, so callers can tell the two apart. rpc DeleteSecret(DeleteSecretRequest) returns (DeleteSecretResponse); // WatchSecrets subscribes to a namespace and pushes an event whenever a // secret under it is added, updated or deleted. // // The stream opens with one ADDED event per secret currently in the // namespace, followed by a single IN_SYNC event. A client can therefore // subscribe without a separate ListSecrets call, and without the race that // list-then-watch would leave between the two. // // Events carry metadata only. To read a rotated value the client calls // GetSecret, so secret bytes stay on one authorized, audited path instead of // flowing down a long-lived stream whose policy was checked once at subscribe // time. rpc WatchSecrets(WatchSecretsRequest) returns (stream WatchSecretsResponse); }
The entire network surface, and the one place secret bytes are allowed to travel. Errors are status codes, never fields; which backend answered is invisible by design.
internal/domain/key.go
// ParseKey validates and constructs a Key from "ns/.../name". func ParseKey(s string) (Key, error) { s = strings.Trim(s, "/") i := strings.LastIndex(s, "/") if i <= 0 || i == len(s)-1 { return Key{}, fmt.Errorf("%w: %q must be namespace/name", ErrInvalidKey, s) } ns, err := ParseNamespace(s[:i]) if err != nil { return Key{}, fmt.Errorf("%w: %v", ErrInvalidKey, err) } name := s[i+1:] if len(name) > maxNameLen || !nameRe.MatchString(name) { return Key{}, fmt.Errorf("%w: bad name %q", ErrInvalidKey, name) } return Key{ns: ns, name: name}, nil } // Contains reports whether other is n itself or nested beneath it. // payments.Contains(payments/prod) == true. func (n Namespace) Contains(other Namespace) bool { if len(other.segments) < len(n.segments) { return false } for i, seg := range n.segments { if other.segments[i] != seg { return false } } return true }
The fields are unexported, so an invalid Key cannot exist anywhere downstream: ParseKey is the single validator, at the edge, and everything below it trusts the type.
internal/domain/secret.go
func NewSecret(meta SecretMeta, value []byte) (Secret, error) { if meta.Key.IsZero() { return Secret{}, ErrInvalidKey } if meta.Version.IsZero() { return Secret{}, ErrInvalidVersion } if len(value) == 0 { return Secret{}, ErrEmptyValue } // Defensive copy: callers (and adapters) must not be able to mutate // the value after construction. v := make([]byte, len(value)) copy(v, value) return Secret{meta: meta, value: v}, nil } // Value returns a copy of the secret's bytes. func (s Secret) Value() []byte { v := make([]byte, len(s.value)) copy(v, s.value) return v } // String redacts the value so a Secret can never be logged by accident. func (s Secret) String() string { return fmt.Sprintf("Secret{%s@%s}", s.meta.Key, s.meta.Version) } // GoString covers %#v as well. func (s Secret) GoString() string { return s.String() }
A value is bytes, copied on the way in and on the way out, and the String method redacts it — so a Secret that reaches a logger by accident prints its key and its version, never its value.
internal/ports/secretstore.go
package ports import ( "context" "errors" "github.com/IbiliAze/vaultlet/internal/domain" ) // ErrNotFound is returned by Get and Delete when no secret exists at the key. // Every backend must wrap this so callers can test with errors.Is. var ErrNotFound = errors.New("secret not found") var ErrReadOnly = errors.New("store is read-only") type SecretStore interface { Get(ctx context.Context, key domain.Key) (domain.Secret, error) Put(ctx context.Context, key domain.Key, value []byte) (domain.SecretMeta, error) List(ctx context.Context, ns domain.Namespace) ([]domain.SecretMeta, error) Delete(ctx context.Context, key domain.Key) error }
The whole port, and the two errors every backend must wrap so the layers above can test with errors.Is. The app-layer Service implements this same interface, so it slots in front of any store.
internal/app/policy.go
// NewPolicy compiles specs into a Policy, rejecting unknown actions and bad // namespaces so a typo fails at boot rather than becoming a silent denial. func NewPolicy(specs []RuleSpec) (Policy, error) { valid := map[Action]bool{ ActionGet: true, ActionList: true, ActionWatch: true, ActionPut: true, ActionDelete: true, } p := make(Policy) for _, spec := range specs { var ns domain.Namespace // zero value: contains every namespace if spec.Namespace != "*" { parsed, err := domain.ParseNamespace(spec.Namespace) if err != nil { return nil, fmt.Errorf("policy: user %q: %w", spec.Principal, err) } ns = parsed } actions := make(map[Action]bool, len(spec.Actions)) for _, a := range spec.Actions { if !valid[Action(a)] { return nil, fmt.Errorf("policy: user %q: unknown action %q", spec.Principal, a) } actions[Action(a)] = true } p[spec.Principal] = append(p[spec.Principal], Rule{namespace: ns, actions: actions}) } return p, nil } // allows reports whether any of the principal's rules grants action at or // above ns. No rule, no access. func (p Policy) allows(principal string, action Action, ns domain.Namespace) bool { for _, r := range p[principal] { if r.actions[action] && r.namespace.Contains(ns) { return true } } return false }
Rules are compiled at boot, so an unknown action or a bad namespace is a startup failure rather than a silent denial. No rule, no access: allows returns false by default.
internal/app/service.go
func (s *Service) Get(ctx context.Context, key domain.Key) (domain.Secret, error) { principal, ok := PrincipalFromContext(ctx) if !ok || !s.policy.allows(principal, ActionGet, key.Namespace()) { audit(ctx, principal, ActionGet, key.String(), "deny", "denied") return domain.Secret{}, ErrPermissionDenied } secret, err := s.store.Get(ctx, key) outcome := "success" if err != nil { outcome = "error" } audit(ctx, principal, ActionGet, key.String(), "allow", outcome) return secret, err } // …Put, List and Delete follow the same shape var _ ports.SecretStore = (*Service)(nil)
The read path. A denied call is audited and returned before the store is touched; an allowed one is audited with its outcome. The principal comes from the context the auth interceptor built.
internal/adapters/driving/grpcserver/auth.go
// errUnauthenticated is the one answer every authentication failure gets. // Distinguishing a missing header from an unknown user or a wrong password // would tell an unauthenticated caller which usernames exist. var errUnauthenticated = status.Error(codes.Unauthenticated, "invalid credentials") // dummyHash is compared against when the username is unknown, so that a // failed lookup costs the same bcrypt work as a wrong password and the two // cannot be told apart by timing. The preimage is throwaway; even a match // is rejected because the user is not in the map. const dummyHash = "$2y$10$sSy2zu.lkhMSh0tzGrFD2eCyfeqkxmpVwEFCDjlm6Nb9Swq.u7sgO" // authenticate resolves the RPC's principal from its "authorization: Basic" // metadata, verifying the password against the configured bcrypt hash. func (s *Server) authenticate(ctx context.Context) (string, error) { md, ok := metadata.FromIncomingContext(ctx) if !ok { return "", errUnauthenticated } auth := md.Get("authorization") if len(auth) == 0 { return "", errUnauthenticated } token, ok := strings.CutPrefix(auth[0], "Basic ") if !ok { return "", errUnauthenticated } raw, err := base64.StdEncoding.DecodeString(token) if err != nil { return "", errUnauthenticated } username, password, ok := strings.Cut(string(raw), ":") if !ok { return "", errUnauthenticated } hash, known := s.users[username] if !known { hash = dummyHash } if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)); err != nil || !known { return "", errUnauthenticated } return username, nil }
Every failure gets the same answer, and an unknown user burns a bcrypt compare against a dummy hash so the two cannot be told apart by timing. Nothing here says which usernames exist.
internal/adapters/driving/grpcserver/server.go
func New(store ports.SecretStore, tlsCfg config.TLSConfig, auth config.AuthConfig) (*Server, error) { s := &Server{store: store} cert, err := tls.LoadX509KeyPair(tlsCfg.CertFile, tlsCfg.KeyFile) if err != nil { return nil, err } // TLS 1.3 floor: the same policy the CLI pins on its side of the dial. creds := credentials.NewTLS(&tls.Config{ Certificates: []tls.Certificate{cert}, MinVersion: tls.VersionTLS13, }) s.users = usersByName(auth.Users) s.grpc = grpc.NewServer( grpc.Creds(creds), grpc.ChainUnaryInterceptor(unaryLogging(), s.unaryAuth()), grpc.ChainStreamInterceptor(streamLogging(), s.streamAuth())) vaultletv1.RegisterSecretServiceServer(s.grpc, s) return s, nil }
The composition of the chain the hero draws: TLS 1.3 as the floor, then logging before authentication so a rejected credential is logged too. There is no plaintext mode to forget to turn off.
internal/adapters/driven/bitwarden/bitwarden.go
// resolveID maps a domain key onto the Bitwarden secret UUID. The SDK only // addresses secrets by UUID, so every lookup by name costs a List of the whole // organization first. func (s *Store) resolveID(key domain.Key) (string, error) { res, err := s.client.Secrets().List(s.orgID) if err != nil { return "", fmt.Errorf("bitwarden: list secrets: %w", err) } name := key.String() for _, ident := range res.Data { if ident.Key == name && s.inProject(ident.ProjectIDS) { return ident.ID, nil } } return "", fmt.Errorf("bitwarden: %s: %w", key, ports.ErrNotFound) } // versionLayout is RFC 3339 with fixed-width nanoseconds. The stdlib's // RFC3339Nano strips trailing zeros, which makes versions vary in width and // sort incorrectly; padding with zeros keeps full precision so two writes in // the same second stay distinguishable. const versionLayout = "2006-01-02T15:04:05.000000000Z07:00" // versionAt derives a domain.Version from a Bitwarden revision date. Bitwarden // has no version identifier of its own, and RevisionDate is the only field that // changes on every write. Every method must build versions through here, or // values from Get and List will not compare equal. func versionAt(t time.Time) (domain.Version, error) { return domain.NewVersion(t.UTC().Format(versionLayout)) }
The two honest costs of the one backend: the SDK addresses secrets by UUID, so a name costs a listing; and Bitwarden has no version of its own, so the revision date is padded into one.
cmd/vaultlet/main.go
func run() error { cfg, err := config.Load() if err != nil { return fmt.Errorf("load config: %w", err) } if err := cfg.Validate(); err != nil { return fmt.Errorf("validate config: %w", err) } store, err := newStore(cfg) if err != nil { return fmt.Errorf("open backend %q: %w", cfg.Backend, err) } if closer, ok := store.(interface{ Close() }); ok { defer closer.Close() } ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() var specs []service.RuleSpec for _, user := range cfg.Auth.Users { for _, rule := range user.Allow { specs = append(specs, service.RuleSpec{ Principal: user.Username, Namespace: rule.Namespace, Actions: rule.Actions, }) } } policy, err := service.NewPolicy(specs) if err != nil { return fmt.Errorf("policy initialisation: %w", err) } app := service.NewService(store, policy) server, err := grpcserver.New(app, cfg.TLS, cfg.Auth) if err != nil { return fmt.Errorf("server initialisation: %w", err) } if err := server.Listen(ctx, cfg.Listen); err != nil { return fmt.Errorf("listen %q: %w", cfg.Listen, err) } return nil }
The composition root, and the only file allowed to know which adapters exist. Config is validated before the backend is opened, rules are compiled before the server is built, and a signal drains it.
Policy
Per-user rules, compiled at boot.
Authentication and authorisation are one YAML file: a bcrypt hash per user, and under it the namespaces each may touch and how. The template below is the repository's own.
vaultlet.example.yml
--- listen: 0.0.0.0:50051 backend: bitwarden tls: cert_file: cert/cert.pem key_file: cert/key.pem bitwarden: api_url: '' identity_url: '' access_token: '' poll_interval: '30s' allow_writes: true auth: users: # bcrypt hash, e.g. `htpasswd -bnBC 10 "" <password> | tr -d ':\n'`. # Clients authenticate with --token set to base64("<username>:<password>"). - username: '' password_hash: '' allow: - namespace: '*' actions: [get, list, watch]
How a rule is read
A rule is a namespace and the actions under it
get, list, watch, put and delete. An unknown action or a malformed namespace fails at boot, not at the first request.
A namespace covers everything beneath it
payments allows payments/prod/DB_URL. The asterisk is the root, and covers every namespace there is.
No rule, no access
A principal with nothing matching is denied and audited. There is no allow-all default to remember to remove.
Listing is filtered, not refused
A principal allowed to list payments/prod may list payments, and sees only the keys their rules cover. A request that overlaps nothing skips the backend entirely.
API surface
One service, and errors are status codes.
The wire types carry no policy, no backend identity and no vendor concept. Which backend served a request is invisible to a client by design, and every failure is a gRPC status rather than a field in a response.
GetSecret
one revision, value included — the only RPC that carries secret bytes
PutSecret
the revision it wrote; FAILED_PRECONDITION on a read-only backend
ListSecrets
metadata for everything at or beneath a namespace, sorted by key, never values
DeleteSecret
nothing; a missing key is NOT_FOUND rather than a silent success
WatchSecrets
a stream: one ADDED per secret, one IN_SYNC, then live changes — declared, not yet served
Status codes
NOT_FOUND
no secret at this key
INVALID_ARGUMENT
the key or namespace fails the grammar
PERMISSION_DENIED
the principal's policy does not allow this action
FAILED_PRECONDITION
the backend is read-only
UNAUTHENTICATED
invalid credentials — one answer for every failure
UNIMPLEMENTED
expected_version was set, or the RPC is WatchSecrets
Run it
A config, a build, a server and a client.
vaultlet
$
cp vaultlet.example.yml vaultlet.yaml
$
make build
$
./bin/vaultlet
$
./bin/vaultlet-cli list payments --server localhost:50051 --ca cert/cert.pem
export VAULTLET_TOKEN=$(printf '%s:%s' user password | base64) # the client sends it as authorization: Basic on every call
What has to exist first
A Bitwarden machine account
its access token, the org id, the project id
A certificate and key
TLS is mandatory; a self-signed pair does for dev
One user
a username and a bcrypt hash of its password
Go 1.26
the codegen tools install themselves into ./bin
Config is read from vaultlet.yaml, then .env, then the environment, highest last, and validated before the backend is opened — so a missing hash or an empty listen address is a one-line failure at startup rather than a surprise on the first call.
Conventions
Deliberate, and enforced across the tree.
Entry point
main() does nothing but call run() error, so deferred cleanup runs
Errors
ports.ErrNotFound and ErrReadOnly wrapped with %w; vendor error types never leave the adapter
Values
[]byte, copied on construction and on read; String() and GoString() redact
Capabilities
Close() is probed with a type assertion, never forced into the port
Wiring
cmd/vaultlet/main.go is the only file that names a concrete adapter
Codegen
buf, protoc-gen-go and protoc-gen-go-grpc pinned in the Makefile; proto-verify fails CI on stale gen/
Current state
What is finished, and what is honestly not.
The read path is complete and verified live against Bitwarden: TLS 1.3, bcrypt authentication, deny-by-default policy, an audit record per call and structured RPC logging. Nothing blocking is left. What remains is the stream the proto promises, a README ahead of the tree, and a lookup that lists the organisation.
WatchSecrets is declared and not served
The proto defines the stream and the CLI already has a complete client for it — snapshot, IN_SYNC, text and JSON rendering, Ctrl-C — but the server answers Unimplemented. Bitwarden has no change feed, so the loop has to diff successive listings; poll_interval is parsed for exactly this and unused.
The README describes a tree the repository does not have
It names file, awssm, oidc, logaudit and watch adapters, a conformance suite, ADRs and an examples directory. None exist; internal/adapters/driven/aws is an empty folder. Read the README as the roadmap and this page as the tree.
Every lookup by name lists the whole organisation
The Bitwarden SDK addresses secrets by UUID only, so resolveID lists the organisation on every get, put and delete. ListSecrets ignores page_size and returns one page. Both are fine on a small vault and will bite on a large one; a short-lived name-to-UUID cache is the obvious first move.
Compare-and-swap is refused, not done
expected_version on put and delete is rejected with Unimplemented rather than ignored, which is the right failure — but it means the CLI’s --expected-version flag can only ever fail. Real CAS on Bitwarden would be a read-compare-write with a race window, and should say so.
Tests stop at the app layer
Policy matching, the principal context and the Service’s four methods are covered. The key grammar, the handlers’ error mapping, both interceptors and the CLI have no tests yet, though the CLI’s command tree was built as a seam for exactly that.
allow_writes defaults to permissive in the template
The example config ships allow_writes: true, and the zero value is open rather than closed. For a service the proto describes as read-mostly, deny-by-default would fail safe; it is an open decision, not a defect.
A permissions gap looks like an empty vault
A machine account with no project access lists zero secrets, which reads as NOT_FOUND to a caller. An organisation listing that returns nothing at all is far more likely a misconfigured account, and is worth logging as such.
Behind the project
Built by Eight Mile in London, as part of our infrastructure and cloud work — the same engineers who build and run systems like it for clients.