mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-09-12 23:02:41 +00:00
145 lines
7.3 KiB
Markdown
145 lines
7.3 KiB
Markdown
# 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-vault` remains the owning shared crate. Replace file persistence with `SecretStore { pool }`.
|
|
- No process-wide entry cache. Fetch on each operation; allow short-lived snapshots for one run/config-resolution operation.
|
|
|
|
## Schema
|
|
|
|
Schema:
|
|
|
|
```sql
|
|
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:
|
|
|
|
- `revision` is internal optimistic concurrency. Ordinary API upserts increment it. OAuth refresh writes `WHERE 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 `timestamptz` behind 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.
|
|
- `value` deliberately 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
|
|
|
|
```rust
|
|
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.
|
|
- `SecretEntry` carries internal revision; `SecretMetadata` and OpenAPI remain unchanged.
|
|
- Derive `EnumString` and `IntoStaticStr` for `SecretType`, 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 `Debug` for a value-bearing type by deriving it. Redact or omit values and OAuth JSON.
|
|
|
|
## Read model and concurrency
|
|
|
|
- Replace `Arc<RwLock<Vault>>` in `AppStores` with `Arc<SecretStore>`.
|
|
- Convert `AppState::vault_secret` to async and fallible. Do not use `try_read()` or map database failure to “missing.”
|
|
- Refactor `VaultCredentialSource`/`CredentialResolver` to 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:
|
|
|
|
1. Load settings and bootstrap `ServerSecrets`.
|
|
2. Connect SQL and run schema migrations.
|
|
3. Normalize any supported legacy vault JSON shape.
|
|
4. Parse and validate the complete `secrets.json`; insert transactionally with `ON CONFLICT(name) DO NOTHING`. SQL wins.
|
|
5. Rename source to `secrets.json.imported-<timestamp>.bak`, preserving private permissions.
|
|
6. Move the temporary optional-`server.env` migration to target `SecretStore`; back up and clean `server.env` only after SQL contains the intended value.
|
|
7. 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
|
|
|
|
1. Add schema, `SecretStore`, row validation, CAS, and store tests in `fabro-vault`.
|
|
2. Add legacy importer and reorder server startup before auth resolution.
|
|
3. Update secrets handlers and all server secret lookups to async/fallible access.
|
|
4. Refactor `fabro-auth` credential resolution and OAuth refresh around snapshots + CAS.
|
|
5. Update install persistence to write SQL. Make direct persistence async; preserve file rollback behavior around settings/`server.env` and use a SQL transaction for the secret batch.
|
|
6. Update CLI/local-run and worker startup to open the same database instead of loading JSON. Keep test helpers behind `test-support` boundaries.
|
|
7. Remove live JSON writes and `Storage::secrets_path()` production consumers. Retain only the legacy import path until its removal deadline.
|
|
8. 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:
|
|
|
|
```text
|
|
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.
|