# 07 — Vault: credential storage

Three built-in backends (`EnvVault`, `KeyringVault`,
`EncryptedFileVault`) + a registry for custom backends. Typed
configs close the closed-box leak that
`VaultSettings.config: dict[str, Any]` left open.

> **Read first:** [`library_common.md`](README.md),
> [`04_settings.md`](settings.md).

---

## Motivation

Every consumer that handles secrets at runtime needs a
credential store: env vars (12-factor / containerized
deployments), system keyring (desktop apps), encrypted file
(headless CI / embedded). Without a shared abstraction, each
consumer re-derives the same three options.

`mgf.common.vault` ships all three behind a single
`VaultProtocol`. Typed configs make the per-backend shape
explicit at the type level. Custom third-party stores
(HashiCorp Vault, AWS Secrets Manager) plug in through the
registry.

---

## Goal

```python
from mgf.common.vault import EnvVault, EnvVaultConfig

config = EnvVaultConfig(prefix="MYAPP_VAULT_")
v = EnvVault(prefix=config.prefix)
v.set("api_key", "secret-value")
api_key = v.get("api_key")
```

`VaultProtocol` lets consumers swap backends with one
line. Audit logging on every op (rule SC-17) — KEY logged,
VALUE not.

---

## Quick start

### EnvVault — stdlib only

```python
from mgf.common.vault import EnvVault

v = EnvVault(prefix="MYAPP_VAULT_")
v.set("api_key", "secret-value") # → MYAPP_VAULT_API_KEY=secret-value
print(v.get("api_key")) # "secret-value"
v.delete("api_key")
```

The right backend for CI / containerized deployments where the
orchestrator injects secrets at process-start time.

### KeyringVault — system credential store

Requires `pip install 'mgf-common[vault]'`. Uses libsecret
(Linux) / Keychain (macOS) / Credential Manager (Windows).

```python
from mgf.common.vault import KeyringVault

v = KeyringVault(service="myapp")
v.set("db_password", "hunter2")
pw = v.get("db_password")
```

The desktop default. Secrets survive across runs without the
consumer managing a passphrase.

### EncryptedFileVault — headless / embedded

Requires `[vault]`. Fernet (AES-128 + HMAC-SHA256) encrypted
JSON-on-disk; PBKDF2-HMAC-SHA256 with 600 000 iterations
(OWASP 2023 baseline per SC-05).

```python
from pathlib import Path
from mgf.common.vault import EncryptedFileVault

v = EncryptedFileVault(
    path=Path("/etc/myapp/vault.dat"),
    passphrase=os.environ["MYAPP_VAULT_PASS"],
)
v.set("api_key", "secret-value")
```

The fallback for headless Linux / embedded CI where no keyring
daemon runs.

---

## Public API

### Concrete backends

| Name | Purpose |
|---|---|
| [`EnvVault(prefix="MGF_VAULT_")`](../../PUBLIC_API.md#mgfcommonvault) | Env-var backend. Stdlib only; no extra needed. |
| [`KeyringVault(service="mgf-common")`](../../PUBLIC_API.md#mgfcommonvault) | System keyring. Consumer apps SHOULD override `service` per SC-12. |
| [`EncryptedFileVault(path, passphrase)`](../../PUBLIC_API.md#mgfcommonvault) | Encrypted JSON file. PBKDF2 600k iterations; 0o600 at-rest. |

### Typed configs

| Name | Purpose |
|---|---|
| [`EnvVaultConfig(prefix=...)`](../../PUBLIC_API.md#mgfcommonvault) | Typed config for `env`. Frozen + `extra="forbid"`. |
| [`KeyringVaultConfig(service=...)`](../../PUBLIC_API.md#mgfcommonvault) | Typed config for `keyring`. |
| [`EncryptedFileVaultConfig(path, passphrase)`](../../PUBLIC_API.md#mgfcommonvault) | Typed config for `file`. Both fields required (pydantic-enforced). |
| [`BuiltinVaultBackendConfig`](../../PUBLIC_API.md#mgfcommonvault) | Discriminated union of the three. `Annotated[..., Field(discriminator="backend_type")]`. |

### Registry

| Name | Purpose |
|---|---|
| [`register_vault_backend(name)`](../../PUBLIC_API.md#mgfcommonvault) | Decorator: register a custom vault-backend factory. |
| [`unregister_vault_backend(name)`](../../PUBLIC_API.md#mgfcommonvault) | No-op if unknown. |
| [`VaultBackendFactory`](../../PUBLIC_API.md#mgfcommonvault) | Type alias: `Callable[[dict[str, Any]], VaultProtocol]`. |
| [`VaultProtocol`](../../PUBLIC_API.md#mgfcommontyping) | The contract. `get / set / delete / list_keys` + `backend` property. `runtime_checkable`. |

Code:
[`src/mgf/common/vault/`](../../src/mgf/common/vault/).

---

## Patterns

### Switching backends with one line

`VaultProtocol` is the seam. Consumer code accepts the
Protocol; the choice happens at bootstrap.

```python
from mgf.common.typing import VaultProtocol

def store_credential(vault: VaultProtocol, name: str, value: str) -> None:
    vault.set(name, value)

# At bootstrap:
if running_in_ci():
    vault = EnvVault(prefix="MYAPP_VAULT_")
elif running_on_desktop():
    vault = KeyringVault(service="myapp")
else:
    vault = EncryptedFileVault(
        path=Path("/etc/myapp/vault.dat"),
        passphrase=load_passphrase(),
    )

store_credential(vault, "api_key", "...")
```

### Custom backend with a typed config (recommended)

The `config_model=` parameter on `register_vault_backend` lets a
custom backend declare its expected config shape. `VaultSettings`
validates incoming dicts against the model at construction —
typo'd field names, missing required keys, and wrong types fail
loudly at config-load time, not at vault-instantiation time.

```python
from typing import Literal

from pydantic import BaseModel, ConfigDict

from mgf.common.typing import VaultProtocol
from mgf.common.vault import register_vault_backend


class HashicorpConfig(BaseModel):
    model_config = ConfigDict(extra="forbid", frozen=True)
    backend_type: Literal["hashicorp"] = "hashicorp"
    url: str
    token: str
    mount: str = "secret"


@register_vault_backend("hashicorp", config_model=HashicorpConfig)
def make_hashicorp_vault(config: HashicorpConfig) -> VaultProtocol:
    import hvac

    client = hvac.Client(url=config.url, token=config.token)
    return MyHashicorpAdapter(client, mount=config.mount)
```

Consumers can then pass a dict (validated up front) or the typed
config directly:

```python
# Dict — validated against HashicorpConfig at construction time:
settings = MgfSettings(
    vault=VaultSettings(
        backend="hashicorp",
        config={"url": "https://vault.local", "token": "..."},
    ),
)
# settings.vault.config is now a HashicorpConfig instance

# Or typed directly:
settings = MgfSettings(
    vault=VaultSettings(
        backend="hashicorp",
        config=HashicorpConfig(url="https://vault.local", token="..."),
    ),
)
```

### Custom backend without a typed config (legacy)

`config_model=` is optional. Backends registered without one keep
the original dict shape — the factory receives the raw dict and
owns its own validation:

```python
@register_vault_backend("legacy-store")
def make_legacy(config: dict[str, Any]) -> VaultProtocol:
    if "url" not in config:
        raise ValueError("legacy-store: missing 'url'")
    return MyLegacyAdapter(config["url"])
```

Use the typed shape for new backends; the legacy shape is fine for
quick experiments and ports of pre-existing custom backends.

### Typed configs for built-in backends

```python
from mgf.common.settings import MgfSettings, VaultSettings
from mgf.common.vault import EnvVaultConfig

settings = MgfSettings(
    vault=VaultSettings(
        backend="env",
        config=EnvVaultConfig(prefix="MYAPP_VAULT_"),
    ),
)
```

Built-in factories accept either typed configs or dicts. Dict shape
on built-ins emits `MgfDeprecationWarning` — prefer the typed
configs (`EnvVaultConfig`, `KeyringVaultConfig`,
`EncryptedFileVaultConfig`) imported from `mgf.common.vault`.

---

## Audit logging

Every vault op emits a structured log record with `audit=true`:

```json
{
  "ts": "...",
  "level": "INFO",
  "msg": "vault.set",
  "audit": true,
  "backend": "env",
  "key": "api_key",
  "service": "myapp"
}
```

KEY is logged; VALUE is not. Consumer-registered redaction
keys (e.g., `passphrase`) are also scrubbed. Per rule SC-17
(audit log on security events).

---

## Links

- **Public API:** [`PUBLIC_API.md` `mgf.common.vault` section](../../PUBLIC_API.md#mgfcommonvault)
- **Engineering standards:**
  [`SECURITY.md`](https://codeberg.org/magogi-admin/mgf-standard/src/branch/main/docs/standards/SECURITY_STANDARD.md) (SC-01 secrets in
  vault; SC-13 KDF; SC-17 audit log)
- **Code:**
  [`src/mgf/common/vault/`](../../src/mgf/common/vault/) —
  `_env_vault.py`, `_keyring_vault.py`, `_file_vault.py`,
  `_configs.py`, `_registry.py`
- **Related docs:**
  - [`04_settings.md`](settings.md) — `VaultSettings`
    sub-model on `MgfSettings`
  - [`02_exceptions.md`](exceptions.md) — `VaultError`
    category
