Skip to main content

Declarative Pydantic-native secrets manager

Project description

vaultly

Declarative, Pydantic-native secrets manager for Python 3.12+.

Mix regular Pydantic fields with secret fields in one model. Secrets are fetched lazily on first access, cached with per-field TTL, masked in repr and model_dump, and never carry a different type than the one you declared:

from vaultly import Secret, SecretModel
from vaultly.backends.aws_ssm import AWSSSMBackend


class AppConfig(SecretModel):
    stage: str = "dev"
    debug: bool = False
    db_password: str = Secret("/db/{stage}/password", ttl=300)
    api_key: str = Secret("/services/openai/key")
    max_conns: int = Secret("/db/{stage}/max_conns")


config = AppConfig(stage="prod", backend=AWSSSMBackend(region_name="eu-west-1"))

config.db_password   # -> str, fetched on first access, cached for 300s
config.max_conns     # -> int, cast from "42"
config.model_dump()  # -> {..., "db_password": "***", "api_key": "***"}

Install

pip install vaultly                 # core (env / mock backends)
pip install 'vaultly[aws]'          # + AWS Systems Manager Parameter Store
pip install 'vaultly[vault]'        # + HashiCorp Vault (KV v2)

Backends

Backend Import Notes
EnvBackend from vaultly import EnvBackend env vars; prefix optional
MockBackend from vaultly.testing.mock import ... for tests; tracks call list
AWSSSMBackend from vaultly.backends.aws_ssm import … batched via GetParameters
VaultBackend from vaultly.backends.vault import … KV v2; path:key selects a field
RetryingBackend from vaultly import RetryingBackend wraps any backend, retries TransientError only

Backends implement a tiny Backend ABC with get(path) -> str and get_batch(paths) -> dict. Bring your own by subclassing.

Path interpolation

{var} placeholders in a secret path are filled from non-secret fields of the root model:

db_password: str = Secret("/db/{stage}/password")

Nested SecretModel fields share the root's context, backend, and cache — they never resolve {var} against their own fields.

Path validation runs at construction time. A typo ({stge} instead of {stage}) fails immediately with MissingContextVariableError instead of six hours later in production.

Casts

The annotated field type drives the cast:

Annotation Cast
str passthrough
int / float direct
bool true/1/yes/onfalse/0/no/off (case-insensitive)
dict / list json.loads

Custom: Secret("/x", transform=...) replaces the default rule entirely.

Validation modes

Set on a subclass via class kwargs:

class AppConfig(SecretModel, validate="fetch", stale_on_error=True):
    ...
  • "paths" (default) — verify every {var} resolves at construction.
  • "fetch" — additionally prefetch() everything via backend.get_batch at construction. Fails fast at startup if any secret is missing.
  • "none" — skip both. Errors surface on first access.

Manual control:

config.prefetch()         # fetch the whole tree now (uses get_batch)
config.refresh("api_key") # invalidate one and re-fetch
config.refresh_all()      # invalidate the whole cache

TTL

ttl= on Secret(...):

  • None (default) — cache forever.
  • 0 — never cache; every access hits the backend.
  • > 0 — seconds.

Versioning

Pin a secret to a specific version:

class AppConfig(SecretModel):
    db_password: str = Secret("/db/password", version=2)

Versioned and unversioned reads of the same path are cached separately. SSM forwards the version as Name=path:N; Vault as version=N to KV v2; other backends ignore it. prefetch() falls back to serial get for versioned secrets (the batch APIs don't support per-path versions).

Description

Free-text description that surfaces in error messages — useful in big models:

db_password: str = Secret(
    "/db/{stage}/password",
    description="postgres prod credentials",
)

Retries

from vaultly import RetryingBackend
from vaultly.backends.aws_ssm import AWSSSMBackend

backend = RetryingBackend(
    AWSSSMBackend(region_name="eu-west-1"),
    max_attempts=3,
    base_delay=0.5,
    max_delay=4.0,
)

Only TransientError is retried (timeouts, throttling, 5xx). Auth and not-found errors are not. Transport-level retries (DNS, TCP resets) stay inside each SDK's own retry config — RetryingBackend is strictly the semantic layer on top.

stale_on_error

Opt in per model. When the backend raises TransientError and the cache holds an expired value, return that with a warning log instead of failing:

class AppConfig(SecretModel, stale_on_error=True):
    db_password: str = Secret("/db/password", ttl=60)

Default is off — for some deployments, returning a stale credential during an outage is worse than a hard failure.

Type checking

pyright works out of the box. mypy users should enable the Pydantic plugin:

# pyproject.toml
[tool.mypy]
plugins = ["pydantic.mypy"]

A type checker sees db_password: str as plain str, both at the field declaration and at the access site.

Errors

VaultlyError
├── ConfigError
│   └── MissingContextVariableError
├── SecretNotFoundError       # not retried
├── AuthError                 # not retried
└── TransientError            # retried by RetryingBackend

Each backend maps SDK exceptions into this hierarchy.

Security model

What vaultly does mask:

  • repr(model), str(model) — secret fields render as "***".
  • model.model_dump() and model.model_dump_json() — same.

What vaultly does not mask:

  • A secret field accessed directly (model.db_password) is a plain str. print(model.db_password), log lines, exception messages, and template expansions can leak it. We chose str over pydantic.SecretStr so that downstream code (DB drivers, HTTP clients) Just Works — but the responsibility to not log it is yours.

  • vars(model) and model.__dict__ bypass __getattribute__ and expose the internal MISSING sentinel for unfetched fields (and fetched values are stored in the cache, not in __dict__, so you'll always see the sentinel there). Use model.model_dump() for introspection — it goes through the masking serializer.

  • pickle.dumps(model), copy.copy(model), copy.deepcopy(model), and model.model_copy() all raise NotImplementedError. Each would either share or duplicate the in-memory cleartext cache and break nested-root linkage. Construct a fresh instance instead.

  • Process memory — secret strings are not zeroed when evicted; this is Python's general posture and would require C-extensions to fix.

  • Logger output — vaultly uses the vaultly logger and emits paths (not values) at WARNING level for stale-on-error and retries. Resolved paths may contain context like {stage} / tenant id; configure your logger if you treat those as PII:

    import logging
    logging.getLogger("vaultly").addFilter(my_pii_scrubber)
    

Concurrency

  • Cache reads and writes are protected by a per-cache threading.Lock.
  • Cold-cache fetches are serialized per resolved key — N threads asking for the same uncached secret produce exactly one backend call.
  • Hot-path reads (cache hit) take only the cache lock and return without touching the per-key fetch lock.
  • Async is not yet supported (planned for a future release). Today, fetches inside an event loop will block; wrap calls in asyncio.to_thread if needed.

Construction paths

vaultly's path validation, _root wiring, and optional prefetch run via a Pydantic model_validator(mode='after'), which fires for both:

  • AppConfig(stage="prod", backend=...) (calls __init__ then validators)
  • AppConfig.model_validate({...}) and .model_validate_json(...) (skip __init__, go straight to validators)

AppConfig.model_construct(...) skips all validation by Pydantic design. You'll get an instance back, but path checks and prefetch don't run; the first attribute access surfaces any errors lazily.

Stability

vaultly follows Semantic Versioning. Breaking changes to the public API ship in a major version bump. The Backend.get(path, *, version=None) signature is the most likely candidate to evolve in a future major release (a SecretQuery-shaped argument is on the table).

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

vaultly-1.0.0.tar.gz (175.9 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

vaultly-1.0.0-py3-none-any.whl (30.0 kB view details)

Uploaded Python 3

File details

Details for the file vaultly-1.0.0.tar.gz.

File metadata

  • Download URL: vaultly-1.0.0.tar.gz
  • Upload date:
  • Size: 175.9 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for vaultly-1.0.0.tar.gz
Algorithm Hash digest
SHA256 9e513d8a293966c90c3c74b882a9bb9530b5939d220e33cb2de51b84b45577bc
MD5 423778ccafe3d08252d2d787eb0679af
BLAKE2b-256 fd4bb317075b1a42c9b0a51885418e223271daf6c1d0d4390be05cb0d40887da

See more details on using hashes here.

File details

Details for the file vaultly-1.0.0-py3-none-any.whl.

File metadata

  • Download URL: vaultly-1.0.0-py3-none-any.whl
  • Upload date:
  • Size: 30.0 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for vaultly-1.0.0-py3-none-any.whl
Algorithm Hash digest
SHA256 229239e93c0c86c2d59d78581ecdbae40b669ba9c7065003cd5dcdcb69d52c89
MD5 9d587bb5515559f13c29cd1fc7ffb04f
BLAKE2b-256 947550d1e32e2570a9a99406b5422ef2424b30ef401441cac958dd43b03a92e5

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page