Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 44 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,11 @@ tokens:
scopes: "*" # "*" for all scopes, or comma-separated list
storage:
- type: "vault"
path: "secret/data/linode/tokens/my-api-token"
# Path is relative to vault.mount_path (omit mount name and "data/").
# With mount_path "secret", this writes secret/data/linode/tokens/my-api-token
path: "linode/tokens/my-api-token"
# key: "token" # optional KV data key (default: token)
# action: "replace" # optional: replace (default) or append

- label: "backup-token"
team: "sre-team"
Expand All @@ -160,9 +164,30 @@ tokens:
rotation_threshold: 15 # Override global threshold for this token
storage:
- type: "vault"
path: "secret/data/linode/tokens/backup"
path: "linode/tokens/backup"
key: "api_token" # for consumers that do not read key "token"
action: "append" # preserve other keys already on this secret
```

### Vault storage fields

| Field | Default | Description |
|-------|---------|-------------|
| `type` | (required) | Only `vault` is supported today |
| `path` | (required) | Secret path **relative to** `vault.mount_path`. Do **not** include a leading `data/` or `{mount}/data/` prefix (a later path segment named `data` is fine). latr writes `{mount_path}/data/{path}` and state to `{mount_path}/metadata/{path}`. |
| `key` | `token` | KV v2 **data** map key for the token string (custom metadata for rotation state is separate). |
| `action` | `replace` | How to write the data map (case-insensitive): |

**`action: replace` (default)** — data map becomes only `{key: <token>}`. Other data keys on that secret are removed. Safe when latr owns the whole secret (typical). Custom metadata is not cleared.

**`action: append`** — read-modify-write with KV v2 check-and-set (CAS) retries: set/overwrite `key`, keep other data keys. Use when the secret is shared with non-latr fields (e.g. multi-key consumer secrets).

AppRole policy implications:

- Both actions need `create`/`update` on `…/data/…` and `…/metadata/…` (for rotation state).
- **`append` also needs `read` on `…/data/…`** so latr can merge existing keys.
- Prefer least-privilege paths (no mount-wide wildcards).

## Usage

### One-Shot Mode
Expand Down Expand Up @@ -351,7 +376,23 @@ latr supports OpenTelemetry for observability:
- `latr_rotations_total{status,label,team}` - Rotation attempts (per token)
- `latr_rotation_duration_seconds{label,team}` - Rotation operation duration
- `latr_token_validity_remaining_seconds{label,team}` - Time until rotation needed
- `latr_vault_storage_errors_total{path}` - Vault write failures
- `latr_vault_storage_errors_total{path,action}` - Vault write failures (`action` is `replace` or `append`)
- `latr_vault_writes_total{action,result}` - Vault KV data writes (`action=replace|append`, `result=success|error`)
- `latr_vault_write_duration_seconds{action}` - Vault KV write latency histogram
- `latr_vault_append_cas_conflicts_total` - Check-and-set version mismatches during `action: append` (retries)

Useful PromQL:

```promql
# Write success rate by action
sum by (action, result) (rate(latr_vault_writes_total[5m]))

# p99 Vault write latency
histogram_quantile(0.99, sum by (le, action) (rate(latr_vault_write_duration_seconds_bucket[5m])))

# CAS contention on shared secrets (append only)
sum(rate(latr_vault_append_cas_conflicts_total[5m]))
```

### Grafana dashboard & alerts (mixin)

Expand Down
17 changes: 14 additions & 3 deletions examples/config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,12 @@ tokens:
scopes: "*" # "*" for all scopes, or comma-separated list
storage:
- type: "vault"
path: "secret/data/linode/tokens/my-api-token"
# Path is relative to vault.mount_path (NOT including mount or "data/").
# Client writes to: {mount_path}/data/{path}
# Example below → secret/data/linode/tokens/my-api-token
path: "linode/tokens/my-api-token"
# key defaults to "token" when omitted
# action defaults to "replace" when omitted

- label: "backup-token"
team: "sre-team"
Expand All @@ -40,12 +45,18 @@ tokens:
rotation_threshold: 15 # Override global threshold for this token
storage:
- type: "vault"
path: "secret/data/linode/tokens/backup"
path: "linode/tokens/backup"

- label: "short-lived-token"
team: "dev-team"
validity: "7d"
scopes: "linodes:read_write"
storage:
- type: "vault"
path: "secret/data/linode/tokens/dev"
path: "linode/tokens/dev"
# Override KV data key for consumers that do not read "token"
# (e.g. legacy Salt / IPService-style secrets)
key: "api_token"
# action: "replace" (default) — secret data becomes only this key
# action: "append" — merge this key into existing secret data (needs AppRole read)
action: "append"
11 changes: 6 additions & 5 deletions helm/latr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,8 @@ config:
scopes: "*"
storage:
- type: "vault"
path: "secret/data/linode/tokens/production"
# relative to config.vault.mountPath (no "data/" prefix)
path: "linode/tokens/production"

secrets:
linodeToken: "your-linode-token"
Expand Down Expand Up @@ -194,7 +195,7 @@ config:
scopes: "*"
storage:
- type: "vault"
path: "secret/data/linode/tokens/my-token"
path: "linode/tokens/my-token"

secrets:
linodeToken: "your-linode-token"
Expand All @@ -217,7 +218,7 @@ config:
rotationThreshold: 10
storage:
- type: "vault"
path: "secret/data/linode/tokens/prod-full"
path: "linode/tokens/prod-full"

- label: "dev-limited-access"
team: "development"
Expand All @@ -226,7 +227,7 @@ config:
rotationThreshold: 20
storage:
- type: "vault"
path: "secret/data/linode/tokens/dev-limited"
path: "linode/tokens/dev-limited"
```

### Example 3: With OpenTelemetry
Expand All @@ -247,7 +248,7 @@ config:
scopes: "*"
storage:
- type: "vault"
path: "secret/data/linode/tokens/monitored"
path: "linode/tokens/monitored"
```

### Example 4: High Availability Setup
Expand Down
5 changes: 4 additions & 1 deletion helm/latr/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,10 @@ config:
# rotationThreshold: 10
# storage:
# - type: vault
# path: secret/data/linode/tokens/my-api-token
# # path is relative to config.vault.mountPath (no "data/" prefix)
# path: linode/tokens/my-api-token
# key: token # optional; default "token"
# action: replace # optional; "replace" (default) or "append" (needs AppRole read)

# Secrets configuration
# These values should be provided via a separate values file or via --set flags
Expand Down
107 changes: 107 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"os"
"regexp"
"strconv"
"strings"
"time"

"gopkg.in/yaml.v3"
Expand Down Expand Up @@ -55,10 +56,48 @@ type TokenConfig struct {
Storage []StorageConfig `yaml:"storage"`
}

// Storage write actions for Vault KV data maps.
const (
// StorageActionReplace replaces the entire secret data map with a single key
// (historical default behavior).
StorageActionReplace = "replace"
// StorageActionAppend merges the token key into existing secret data, preserving
// other keys. If the secret does not exist, behaves like replace for that write.
StorageActionAppend = "append"
)

// StorageConfig represents where to store the rotated token
type StorageConfig struct {
Type string `yaml:"type"`
Path string `yaml:"path"`
// Key is the KV v2 data key written/read for the token value.
// Defaults to "token" when empty (ApplyDefaults). Override for consumers
// that expect a different key (e.g. IPService / Salt cutovers).
Key string `yaml:"key,omitempty"`
// Action controls how the key is written into the secret data map:
// - "replace" (default): data map becomes only {key: token}
// (destructive to other data keys on that secret)
// - "append": merge key into existing data with CAS retries; AppRole
// needs read+create+update on the data path
Action string `yaml:"action,omitempty"`
}

// NormalizeStorageAction trims and lowercases action; empty becomes DefaultStorageAction.
func NormalizeStorageAction(action string) string {
a := strings.ToLower(strings.TrimSpace(action))
if a == "" {
return DefaultStorageAction
}
return a
}

// NormalizeStorageKey trims key; empty becomes DefaultStorageKey.
func NormalizeStorageKey(key string) string {
k := strings.TrimSpace(key)
if k == "" {
return DefaultStorageKey
}
return k
}

// Parse parses YAML configuration data into a Config struct.
Expand Down Expand Up @@ -88,6 +127,12 @@ func Parse(data []byte) (*Config, error) {
return &cfg, nil
}

// DefaultStorageKey is the KV v2 data key used when storage.key is omitted.
const DefaultStorageKey = "token"

// DefaultStorageAction is used when storage.action is omitted.
const DefaultStorageAction = StorageActionReplace

// ApplyDefaults sets default values for optional configuration fields
func (c *Config) ApplyDefaults() {
if c.Daemon.Mode == "" {
Expand All @@ -105,6 +150,12 @@ func (c *Config) ApplyDefaults() {
if c.Observability.LogLevel == "" {
c.Observability.LogLevel = "info"
}
for i := range c.Tokens {
for j := range c.Tokens[i].Storage {
c.Tokens[i].Storage[j].Key = NormalizeStorageKey(c.Tokens[i].Storage[j].Key)
c.Tokens[i].Storage[j].Action = NormalizeStorageAction(c.Tokens[i].Storage[j].Action)
}
}
}

// Validate checks that the configuration is valid
Expand Down Expand Up @@ -148,6 +199,12 @@ func (c *Config) validateToken(token *TokenConfig, index int) error {
return fmt.Errorf("token[%d]: at least one storage backend is required", index)
}

for j, storage := range token.Storage {
if err := validateStorage(&storage, index, j, c.Vault.MountPath); err != nil {
return err
}
}

// Validate validity period
duration, err := ParseValidityDuration(token.Validity)
if err != nil {
Expand All @@ -163,6 +220,56 @@ func (c *Config) validateToken(token *TokenConfig, index int) error {
return nil
}

func validateStorage(storage *StorageConfig, tokenIndex, storageIndex int, mountPath string) error {
if storage.Type == "" {
return fmt.Errorf("token[%d].storage[%d]: type is required", tokenIndex, storageIndex)
}
if strings.TrimSpace(storage.Path) == "" {
return fmt.Errorf("token[%d].storage[%d]: path is required", tokenIndex, storageIndex)
}

// Path is relative to vault.mount_path; client writes {mount}/data/{path}.
// Reject mistaken API-style prefixes, but allow a path segment named "data"
// that is not the first segment (e.g. "team/data/token" is valid).
trimmedPath := strings.Trim(strings.TrimSpace(storage.Path), "/")
if trimmedPath == "data" || strings.HasPrefix(trimmedPath, "data/") {
return fmt.Errorf(
"token[%d].storage[%d]: path %q must be relative to vault.mount_path without a leading \"data/\" prefix (example: \"shared-all/team/token\", not \"data/team/token\")",
tokenIndex,
storageIndex,
storage.Path,
)
}
mount := strings.Trim(strings.TrimSpace(mountPath), "/")
if mount != "" {
// Catch "infra/data/..." when mount_path is "infra" (double data/ in Vault).
if trimmedPath == mount+"/data" || strings.HasPrefix(trimmedPath, mount+"/data/") {
return fmt.Errorf(
"token[%d].storage[%d]: path %q must not include vault.mount_path %q or the \"data/\" API prefix (use the path under the mount only)",
tokenIndex,
storageIndex,
storage.Path,
mount,
)
}
}

switch NormalizeStorageAction(storage.Action) {
case StorageActionReplace, StorageActionAppend:
// ok (empty normalizes to replace)
default:
return fmt.Errorf(
"token[%d].storage[%d]: invalid action %q (want %q or %q)",
tokenIndex,
storageIndex,
storage.Action,
StorageActionReplace,
StorageActionAppend,
)
}
return nil
}

// ParseValidityDuration parses a validity string (e.g., "90d", "6mo") into a time.Duration
func ParseValidityDuration(validity string) (time.Duration, error) {
// Support formats: 90d, 6mo, 1h, 30m
Expand Down
Loading