Metadata-Version: 2.5
Name: openbao-pydantic-settings-adapter
Version: 1.1.0
Summary: OpenBao/Vault secrets source for pydantic-settings with AppRole authentication
Project-URL: Homepage, https://github.com/itstandart/openbao-pydantic-settings-adapter
Project-URL: Repository, https://github.com/itstandart/openbao-pydantic-settings-adapter
Project-URL: Documentation, https://github.com/itstandart/openbao-pydantic-settings-adapter#readme
Project-URL: Issues, https://github.com/itstandart/openbao-pydantic-settings-adapter/issues
Author-email: IT Standart <aitistandart@gmail.com>
License-Expression: LicenseRef-PolyForm-Noncommercial-1.0.0
License-File: LICENSE
Keywords: approle,configuration,openbao,pydantic,pydantic-settings,secrets,vault
Classifier: Development Status :: 4 - Beta
Classifier: Framework :: Pydantic :: 2
Classifier: Intended Audience :: Developers
Classifier: License :: Other/Proprietary License
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Programming Language :: Python :: 3.15
Classifier: Topic :: Security
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Typing :: Typed
Requires-Python: >=3.11
Requires-Dist: httpx>=0.28.0
Requires-Dist: pydantic-settings>=2.0.0
Requires-Dist: pydantic>=2.10.0
Requires-Dist: python-dotenv>=1.0.0
Provides-Extra: dev
Requires-Dist: mypy>=1.13.0; extra == 'dev'
Requires-Dist: ruff>=0.8.0; extra == 'dev'
Description-Content-Type: text/markdown

# openbao-pydantic-settings-adapter

[![PyPI version](https://badge.fury.io/py/openbao-pydantic-settings-adapter.svg)](https://pypi.org/project/openbao-pydantic-settings-adapter/)
[![Python 3.11+](https://img.shields.io/badge/python-3.11+-blue.svg)](https://www.python.org/downloads/)
[![License: PolyForm Noncommercial](https://img.shields.io/badge/License-PolyForm%20Noncommercial-blue.svg)](https://polyformproject.org/licenses/noncommercial/1.0.0/)

OpenBao/Vault secrets source for [pydantic-settings](https://docs.pydantic.dev/latest/concepts/pydantic_settings/) with AppRole authentication, automatic token renewal, and graceful fallback to environment variables.

## Features

- **AppRole Authentication** - Secure machine-to-machine authentication
- **Automatic Token Renewal** - TokenManager handles TTL-based token lifecycle
- **Namespace Support** - Multi-tenant isolation (team/project level)
- **Two-Path Architecture** - Separate `secrets` and `supersecrets` paths with deep merge
- **KV Version Tracking** - Independent source versions and optional reads of an exact version pair
- **SecretStr Validation** - Enforces `SecretStr` for supersecrets to prevent log exposure
- **Graceful Degradation** - Falls back to `.env` when OpenBao is unavailable
- **Type Safety** - Full type annotations with `py.typed` marker (PEP 561)

## Installation

```bash
pip install openbao-pydantic-settings-adapter
```

**Note:** The import name is `openbao_settings`:

```python
from openbao_settings import OpenBaoSettingsSource
```

## Quick Start

```python
from pydantic import SecretStr
from pydantic_settings import BaseSettings, PydanticBaseSettingsSource

from openbao_settings import OpenBaoSettingsSource


class Settings(BaseSettings):
    database_url: str
    api_key: SecretStr  # Fields from supersecrets MUST use SecretStr

    @classmethod
    def settings_customise_sources(
        cls,
        settings_cls: type[BaseSettings],
        init_settings: PydanticBaseSettingsSource,
        env_settings: PydanticBaseSettingsSource,
        dotenv_settings: PydanticBaseSettingsSource,
        file_secret_settings: PydanticBaseSettingsSource,
    ) -> tuple[PydanticBaseSettingsSource, ...]:
        return (
            init_settings,
            OpenBaoSettingsSource(settings_cls),  # OpenBao has priority
            env_settings,
            dotenv_settings,
        )


settings = Settings()
```

### Validated API Paths

Use `ApiPath` for API endpoint settings stored in OpenBao or environment variables:

```python
from pydantic import BaseModel, EmailStr, HttpUrl

from openbao_settings import ApiPath, TemplateApiPath


class EmailApi(BaseModel):
    url: HttpUrl
    send_email: ApiPath
    health: ApiPath
    log: EmailStr
    settings: ApiPath
    message: TemplateApiPath  # e.g. "messages/{message_id}/"
```

`ApiPath` is a strict string type: it requires a trailing slash, forbids a leading one,
rejects double slashes, and only allows characters from `[a-zA-Z0-9/_-]`. The value
must already be normalized (e.g. `api/health/`, not `/api/health` or `api/health`).

`TemplateApiPath` covers routes with a dynamic entity identifier. Since `ApiPath` rejects
`{` and `}`, this subtype requires at least one `{name}` placeholder and applies all
`ApiPath` rules to the rest of the path. Placeholder names use Starlette's
path-parameter charset (`[a-zA-Z_][a-zA-Z0-9_]*`), so `{bucket}`, `{task_id}` and `{mailId}`
are all valid. The value is a `str.format` template for outgoing calls, not a route to
register, so a name may repeat (`a/{id}/{id}/`) even though Starlette rejects that.
Placeholders are kept in the value, so it stays formattable:

```python
routes = EmailApi(...)
routes.message.format(message_id=42)  # "messages/42/"
```

## Configuration

Configure via environment variables:

| Variable | Description | Required |
|----------|-------------|----------|
| `BAO_ADDR` | OpenBao server URL (e.g., `http://localhost:8200`) | Yes |
| `BAO_SECRET_PATH` | Base path to secrets (e.g., `myapp`) | Yes |
| `BAO_APPROLE_ROLE_ID` | AppRole Role ID | Yes |
| `BAO_APPROLE_SECRET_ID` | AppRole Secret ID | Yes |
| `BAO_NAMESPACE` | OpenBao namespace for multi-tenant isolation | No |
| `BAO_MOUNT_POINT` | KV engine mount point (default: `kv`) | No |
| `BAO_TIMEOUT` | Connection timeout in seconds (default: `30`) | No |
| `BAO_TOKEN_RENEWAL_THRESHOLD` | When to renew token (default: `0.75` = at 75% of TTL) | No |

For Docker/Kubernetes, you can use file-based credentials:
- `BAO_APPROLE_ROLE_ID_FILE`
- `BAO_APPROLE_SECRET_ID_FILE`
- `BAO_NAMESPACE_FILE`

## Secrets Architecture

The library reads from two paths and merges them:

```
kv/{BAO_SECRET_PATH}/secrets      - Regular secrets (developers can view/edit)
kv/{BAO_SECRET_PATH}/supersecrets - Sensitive secrets (admin only)
```

**Important:** Fields loaded from `supersecrets` MUST use `SecretStr` type annotation. The library validates this at runtime and raises `SecurityMisconfigurationError` if violated.

```python
# Wrong - will raise SecurityMisconfigurationError
class Settings(BaseSettings):
    api_key: str  # Loaded from supersecrets but not SecretStr!

# Correct
class Settings(BaseSettings):
    api_key: SecretStr  # Properly protected
```

## Token Lifecycle

`TokenManager` automatically handles token renewal:

1. Caches tokens per `(namespace, role_id)` combination
2. Renews at 75% of TTL (configurable via `BAO_TOKEN_RENEWAL_THRESHOLD`)
3. Thread-safe for multi-threaded applications
4. Graceful degradation if OpenBao becomes unavailable

```python
from openbao_settings import TokenManager

# Manual token invalidation (e.g., for credential rotation)
TokenManager().invalidate()

# Check token health
TokenManager().is_healthy(namespace="myapp", role_id="...")
```

## Diagnostics

Track where settings were loaded from:

```python
from openbao_settings import get_last_source_info, SourceInfo

# Basic info (only OpenBao fields in field_sources)
info: SourceInfo = get_last_source_info()
print(info.status)              # True if loaded from OpenBao
print(info.source)              # "openbao" or "env"
print(info.details)             # Human-readable description
print(info.openbao_keys_loaded) # Number of keys from OpenBao
print(info.timestamp)           # When the check was performed

# Full field tracking (pass settings instance)
settings = Settings()
info = get_last_source_info(settings)
print(info.field_sources)
# {
#   "db.host": "openbao/secrets",
#   "db.port": "openbao/secrets",
#   "db.password": "openbao/supersecrets",
#   "services.otlp.domain": "openbao/secrets",
#   "services.otlp.password": "openbao/supersecrets",
#   "log_level": "env",
#   "debug": "default"
# }
```

Field source values (`FieldSourceType`) with **dot notation** for nested fields:
- `"openbao/secrets"` — loaded from secrets/ path (e.g., `"db.host"`)
- `"openbao/supersecrets"` — loaded from supersecrets/ path (e.g., `"db.password"`)
- `"openbao/merged"` — same leaf field in BOTH paths (supersecrets wins)
- `"env"` — loaded from environment variables or .env file
- `"default"` — uses field's default value

`info.unmapped_keys` lists OpenBao keys with no matching model field, in the same `{path: source}` format (e.g. a typo `"smtp.providers.backup.logni": "openbao/secrets"`). Nested models, field aliases, `list[Model]`, `dict[str, Any]` and `dict[str, Model]` are understood: for `dict[str, Model]` any key (provider id) is valid and each value is checked against `Model`.

### Source versions and coordinated reloads

`SourceInfo.secrets_version` and `SourceInfo.supersecrets_version` report the independent
KV v2 versions from the same responses used to load the data. No additional metadata
request is made. Changing only a password can produce `secrets_version=12` and
`supersecrets_version=6`; the numbers do not need to match. A version is `None` in env
mode or when the response did not include it.

For a source snapshot tied to a particular load, use `load_settings` with the Settings
class from Quick Start. It keeps the same source order and forwards additional keyword
arguments to the Settings constructor:

```python
from openbao_settings import load_settings

# API: read the latest versions and keep their source snapshot with these settings.
settings, target = load_settings(Settings)
print(target.secrets_version, target.supersecrets_version)  # e.g. 12, 5

# Worker: independently read exactly the same pair, even if OpenBao has changed.
if target.source != "openbao" or target.secrets_version is None or target.supersecrets_version is None:
    raise RuntimeError("Версии источников OpenBao неизвестны")

worker_settings, loaded = load_settings(
    Settings,
    secrets_version=target.secrets_version,
    supersecrets_version=target.supersecrets_version,
)
```

Both version arguments are optional positive integers. Omitting a version reads the
latest version of that source; omitting both preserves the existing env fallback.
When either version is specified, both OpenBao sources must be available with valid
version metadata. Missing, deleted, destroyed or mismatched versions fail the load;
they are never replaced by latest data or env fallback. Connection and read failures
also fail this explicit-version load. Existing source precedence and `SecretStr`
validation still apply.

An available version may have a future `deletion_time` when automatic deletion is
configured. This timestamp alone does not make it unavailable; OpenBao rejects reads
after deletion. Explicit-version failures include the source name, exception class
and HTTP status when available, without the response body or secret values.
Exact reads are also subject to OpenBao's version retention (`max_versions`): a
version removed from history cannot be loaded, even if its number is known.

Reading exact versions does not disable the other Pydantic settings sources. Fields
absent from OpenBao can still come from constructor arguments, env or defaults,
according to the settings model's existing source order. To reproduce the same
configuration across processes, keep runtime fields in OpenBao and avoid env overrides
for those fields; inspect `SourceInfo.field_sources` to diagnose mixed sources.

Each returned `SourceInfo` belongs to that call and survives later or concurrent loads.
`get_last_source_info(settings)` remains a diagnostic for the most recent load; its
settings argument expands field tracking, not historical association. Use the tuple
from `load_settings` when correlating API and worker reads.

Versions identify the OpenBao data read, not constructor/env overrides or whether a
worker applied the settings. The application must apply its runtime configuration and
acknowledge success separately. Reading the latest sources uses two requests and is
not an atomic snapshot of changes to both paths. The library does not coordinate
workers, poll for changes, or update settings automatically.

## API Reference

### Classes

- `OpenBaoSettingsSource` - Main settings source for pydantic-settings
- `OpenBaoClient` - Low-level HTTP client for OpenBao API
- `TokenManager` - Singleton for token lifecycle management
- `TokenEntry` - Dataclass for cached token metadata (token, expires_at, ttl)
- `ApiPath` - Validated API path string for service endpoint settings
- `TemplateApiPath` - `ApiPath` with at least one required `{name}` placeholder for dynamic routes

### Exceptions

- `OpenBaoError` - Base exception for all OpenBao errors
- `InvalidPathError` - Path not found (HTTP 404)
- `InvalidRequestError` - Invalid request/credentials (HTTP 400)
- `ForbiddenError` - Access denied (HTTP 403)
- `InvalidResponseError` - Unexpected API response structure
- `SecurityMisconfigurationError` - SecretStr validation failed

### Response Models

- `AppRoleLoginResponse` - AppRole authentication response
- `AuthInfo` - Auth block with token, TTL, policies (part of `AppRoleLoginResponse.auth`)
- `KvV2ReadResponse` - KV v2 read response
- `SecretDataWrapper` - Inner data wrapper for KV v2 secrets (part of `KvV2ReadResponse.data`)
- `SourceInfo` - Settings source diagnostic info
- `FieldSourceType` - Type alias for field source values (`Literal["openbao/secrets", "openbao/supersecrets", "openbao/merged", "env", "default"]`)

### Utilities

- `get_last_source_info(settings=None)` - Get info about last settings load source
- `load_settings(Settings, *, secrets_version=None, supersecrets_version=None, **kwargs)` - Return settings and their source snapshot; optionally read exact KV versions
- `deep_merge(base, override)` - Recursively merge dictionaries (override wins)

The low-level `OpenBaoClient.kv_v2_read(path, mount_point="kv", *, version=None)` also
supports a specific version and checks that the returned version matches it.

## Development

### Syncing with Remote

```bash
# Fetch commits and tags
git pull origin main --tags

# If local branch is behind remote
git reset --hard origin/main
```

### Verify sync status

```bash
git log --oneline origin/main -5
git diff origin/main
```

### Checks

```bash
uv run pytest        # unit tests, no OpenBao needed
uv run ruff check .
uv run pyright
```

## CI/CD

The pipeline consists of two stages:

1. **auto_tag** — on push to `main`, reads `__version__` from `__init__.py` and automatically creates a tag (if it doesn't exist)

2. **mirror_to_github** — on tag creation, mirrors the repository to GitHub (removing `.gitlab-ci.yml`)

### Release flow

1. Update `__version__` in `src/openbao_settings/__init__.py`
2. Push to main
3. CI creates tag → mirrors to GitHub → publishes to PyPI

## Versioning

This project follows [Semantic Versioning](https://semver.org/) (MAJOR.MINOR.PATCH):

- **PATCH** — bug fixes, documentation, metadata
- **MINOR** — new features (backwards compatible)
- **MAJOR** — breaking changes

## Changelog

### 1.1.0

Added independent `secrets_version` / `supersecrets_version` diagnostics and
`load_settings` for per-load snapshots and explicit version reads. The low-level KV
client accepts `version`. Explicit-version loads fail instead of falling back to env
or accepting another version. Existing latest-version loading remains the default.

Scheduled deletion timestamps no longer reject available versions. Explicit-version
errors retain safe cause and HTTP status diagnostics. Ordinary loads preserve their
previous handling of invalid JSON (abort), schema errors and unavailable paths
(existing env fallback). No response bodies are logged for invalid KV responses.
Python 3.15 is included in the compatibility classifiers.

### 1.0.10

Fixed false `unmapped_keys` for `dict[str, Model]` fields (e.g. `smtp.providers.backup.login`): any key is accepted as an item id and each value is checked against `Model`, so a real typo is still reported with its full path (`smtp.providers.backup.logni`). Unmapped keys are now found by walking the data alongside the model, which also removes false positives for `list[Model]` fields and for nested models behind a field alias. Diagnostics only: loading, validation and the supersecrets `SecretStr` checks are unchanged. First regression tests added (`uv run pytest`).

### 1.0.9

`TemplateApiPath` accepts any named placeholder instead of the hardcoded `{id}`. A value must carry at least one `{name}` placeholder; names use Starlette's path-parameter charset (`[a-zA-Z_][a-zA-Z0-9_]*`), so `{bucket}`, `{task_id}` and `{mailId}` all work and a path may carry several of them. Strictly more permissive: every value valid in 1.0.8 stays valid.

### 1.0.8

`TemplateApiPath` — `ApiPath` subtype for routes with a dynamic entity identifier. Requires the `{id}` placeholder (which `ApiPath` itself rejects, since `{` and `}` are not allowed characters) and applies all other `ApiPath` rules to the substituted path. The placeholder is preserved in the value, so `path.format(id=42)` yields a valid `ApiPath`.

### 1.0.7

`ApiPath` — validated `str` subtype for service endpoint settings stored in OpenBao or env. Requires a trailing slash, forbids a leading one, rejects `//`, and only allows characters from `[a-zA-Z0-9/_-]`. Use as a Pydantic field type (e.g. `health: ApiPath`) to surface misconfigured paths as validation errors at startup instead of silently letting them through.

### 1.0.6

`unmapped_keys` field added to `SourceInfo` — detects OpenBao keys (after merge of `secrets` and `supersecrets`) that have no corresponding field in the Pydantic Settings model. Logged as a warning at startup and exposed via `get_last_source_info(settings)`. Supports nested models, field aliases, and `dict[str, Any]` fields. Uses the same `{path: source}` format as `field_sources`.

### 1.0.5

`SourceInfo` model for diagnostics — standardized format for reporting where settings came from, with per-field tracking using dot notation (e.g. `db.password` → `openbao/supersecrets`).
`FieldSourceType` literal — `"openbao/secrets" | "openbao/supersecrets" | "openbao/merged" | "env" | "default"`.
`get_last_source_info(settings=None)` — when called with a settings instance, returns full per-field source tracking including env and default values; without an instance, returns only OpenBao fields.
README API Reference expanded to cover all exported symbols.

### 1.0.4

Initial `SourceInfo` model (extended in 1.0.5 with `field_sources` tracking).

### 1.0.3

Minimum supported Python lowered from 3.13 to 3.11. `requires-python` in `pyproject.toml` changed to `>=3.11`, Python 3.11 and 3.12 added to the trove classifiers. Note: the corresponding release commit is mistakenly titled `Release 1.02`, but the published PyPI version is `1.0.3`.

### 1.0.2

Maintenance release: `__version__` bump only, no API-visible changes.

### 1.0.1

Initial public release on PyPI. Provides `OpenBaoSettingsSource` for `pydantic-settings` with AppRole authentication, automatic token renewal via `TokenManager`, namespace support, two-path architecture (`secrets` / `supersecrets`) with deep merge, `SecretStr` enforcement for supersecrets, and graceful `.env` fallback when OpenBao is unavailable.

## License

This project is licensed under the **PolyForm Noncommercial License 1.0.0**.

**You may use this software for noncommercial purposes only.**

See [LICENSE](LICENSE) for the full license text, or visit [polyformproject.org](https://polyformproject.org/licenses/noncommercial/1.0.0/).
