7.3 KiB
Secrets to SQLite sketch
Goal
Move optional integration secrets from vaults/default/secrets.json to the shared SQL database. Preserve the REST contract and secret-type behavior. Make reads and OAuth refresh safe across nodes. Keep bootstrap secrets in process env or server.env.
Encryption-at-rest is out of scope. This migration preserves the current plaintext-at-rest semantics; application-level encryption will be a separate PR.
Boundary
- SQL: token, OAuth, and file secrets currently owned by
fabro-vault. - Not SQL:
SESSION_SECRET, dev token, database/object-store bootstrap credentials, worker bearer tokens. fabro-vaultremains the owning shared crate. Replace file persistence withSecretStore { pool }.- No process-wide entry cache. Fetch on each operation; allow short-lived snapshots for one run/config-resolution operation.
Schema
Schema:
CREATE TABLE secrets (
name TEXT PRIMARY KEY NOT NULL,
secret_type TEXT NOT NULL,
value TEXT NOT NULL,
description TEXT,
revision INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
CHECK (length(name) > 0),
CHECK (secret_type IN ('token', 'oauth', 'file')),
CHECK (revision > 0),
CHECK (length(created_at) > 0),
CHECK (length(updated_at) > 0),
CHECK (
(secret_type IN ('token', 'oauth')
AND substr(name, 1, 1) GLOB '[A-Za-z_]'
AND name NOT GLOB '*[^A-Za-z0-9_]*')
OR
(secret_type = 'file'
AND substr(name, 1, 1) = '/'
AND substr(name, -1, 1) <> '/')
)
);
Notes:
revisionis internal optimistic concurrency. Ordinary API upserts increment it. OAuth refresh writesWHERE name = ? AND revision = ?; a loser reloads the winning credential.- Keep timestamps as RFC 3339 text to match current SQLite tables. PostgreSQL migration can map them to
timestamptzbehind the store API. - Keep full semantic validation in Rust and revalidate decoded database rows. SQLite checks provide defense in depth; PostgreSQL gets equivalent backend-specific constraints later.
valuedeliberately preserves current plaintext-at-rest behavior. A follow-up encryption PR should migrate it to a versioned ciphertext envelope and introduce key provisioning/rotation without changing the caller-facing API.
No secondary index: secret sets are small and every supported query is primary-key lookup or full sorted listing.
Store API
pub struct SecretStore { /* pool */ }
pub async fn get(&self, name: &str) -> Result<Option<SecretEntry>, Error>;
pub async fn list(&self) -> Result<Vec<SecretMetadata>, Error>;
pub async fn set(
&self,
name: &str,
value: &str,
secret_type: SecretType,
description: Option<&str>,
) -> Result<SecretMetadata, Error>;
pub async fn remove(&self, name: &str) -> Result<(), Error>;
pub async fn replace_if_revision(
&self,
expected_revision: i64,
entry: SecretReplacement<'_>,
) -> Result<SecretEntry, Error>;
pub async fn snapshot(&self) -> Result<SecretSnapshot, Error>;
- Preserve created time and existing description when an upsert omits description.
SecretEntrycarries internal revision;SecretMetadataand OpenAPI remain unchanged.- Derive
EnumStringandIntoStaticStrforSecretType, aligned with serde/strum names. - Typed errors: invalid name, not found, stale revision, invalid stored row, database failure, legacy import failure. Preserve sources; API handlers return curated messages.
- Never implement
Debugfor a value-bearing type by deriving it. Redact or omit values and OAuth JSON.
Read model and concurrency
- Replace
Arc<RwLock<Vault>>inAppStoreswithArc<SecretStore>. - Convert
AppState::vault_secretto async and fallible. Do not usetry_read()or map database failure to “missing.” - Refactor
VaultCredentialSource/CredentialResolverto load an operation-scoped snapshot. Credential resolution stays internally synchronous over that snapshot; storage I/O is async at the boundary. - OAuth refresh: load entry + revision, refresh remotely, CAS the new credential. On stale revision, reload and use the winner if valid; never overwrite a newer refresh token.
- Workflow/run creation and worker startup may take one snapshot for consistent interpolation and credential selection. A running worker does not observe secret rotation; the next request or worker sees later writes from any node.
Startup and legacy import
New order:
- Load settings and bootstrap
ServerSecrets. - Connect SQL and run schema migrations.
- Normalize any supported legacy vault JSON shape.
- Parse and validate the complete
secrets.json; insert transactionally withON CONFLICT(name) DO NOTHING. SQL wins. - Rename source to
secrets.json.imported-<timestamp>.bak, preserving private permissions. - Move the temporary optional-
server.envmigration to targetSecretStore; back up and cleanserver.envonly after SQL contains the intended value. - Query required vault-only startup secrets, then resolve auth and integrations.
Import is state-driven and retry-safe. Missing source is a no-op. Invalid JSON/name/type/OAuth payload leaves the source untouched. Logs contain paths, counts, and names only—never values or serialized rows.
Call-site migration
- Add schema,
SecretStore, row validation, CAS, and store tests infabro-vault. - Add legacy importer and reorder server startup before auth resolution.
- Update secrets handlers and all server secret lookups to async/fallible access.
- Refactor
fabro-authcredential resolution and OAuth refresh around snapshots + CAS. - Update install persistence to write SQL. Make direct persistence async; preserve file rollback behavior around settings/
server.envand use a SQL transaction for the secret batch. - Update CLI/local-run and worker startup to open the same database instead of loading JSON. Keep test helpers behind
test-supportboundaries. - Remove live JSON writes and
Storage::secrets_path()production consumers. Retain only the legacy import path until its removal deadline. - Update operator docs: backup behavior, plaintext-at-rest scope, and SQLite/PostgreSQL boundary. Track application-level encryption as a separate follow-up.
Tests
- Schema constraints and invalid stored-row decoding.
- CRUD parity: sorting, empty values, all types, description preservation, timestamps.
- Two independent pools observe writes immediately.
- Concurrent upsert and delete behavior.
- OAuth CAS: one winner; loser reloads without clobbering rotated refresh token.
- Redacted
Debug/errors and no secret values in diagnostics. - Import: absent, success, second-run no-op, SQL wins, invalid file unchanged, backup permissions, rename failure retry.
- Startup auth reads imported GitHub client secret before auth validation.
- Install, CLI local run, worker, API persistence, restart, and multi-pool integration coverage.
- Assert logs/errors/API bodies never contain fixture secret values.
Verification:
cargo nextest run -p fabro-db -p fabro-vault -p fabro-auth
cargo nextest run -p fabro-server --features test-support secrets
cargo nextest run -p fabro-server --features test-support install
cargo nextest run -p fabro-cli --features test-support
cargo build --workspace
cargo +nightly-2026-04-14 fmt --check --all
cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings
Unresolved questions
None.