refactor: entries + secrets 双表,search 展示 field schema,key_ref PEM 共享
Some checks failed
Secrets CLI - Build & Release / 质量检查 (fmt / clippy / test) (push) Successful in 1m57s
Secrets CLI - Build & Release / 版本 & Release (push) Successful in 3s
Secrets CLI - Build & Release / Build (macOS aarch64 + x86_64) (push) Successful in 51s
Secrets CLI - Build & Release / Build (x86_64-unknown-linux-musl) (push) Successful in 1m6s
Secrets CLI - Build & Release / 发布草稿 Release (push) Has been cancelled
Secrets CLI - Build & Release / Build (x86_64-pc-windows-msvc) (push) Has been cancelled

- secrets 表拆为 entries(主表)+ secrets(每字段一行)
- search 无需 master_key 即可展示 secrets 字段名、类型、长度
- inject/run 支持 metadata.key_ref 引用 kind=key 记录,PEM 轮换 O(1)
- entries_history + secrets_history 字段级历史,rollback 按 version 恢复
- 移除迁移用 DROP 语句,migrate 幂等
- v0.8.0

Made-with: Cursor
This commit is contained in:
voson
2026-03-19 15:18:12 +08:00
parent 0a5317e477
commit e1cd6e736c
13 changed files with 1000 additions and 381 deletions

View File

@@ -4,7 +4,7 @@ use sqlx::PgPool;
use std::collections::HashMap;
use crate::crypto;
use crate::models::Secret;
use crate::models::{Entry, SecretField};
use crate::output::{OutputMode, format_local_time};
pub struct SearchArgs<'a> {
@@ -13,7 +13,6 @@ pub struct SearchArgs<'a> {
pub name: Option<&'a str>,
pub tags: &'a [String],
pub query: Option<&'a str>,
pub show_secrets: bool,
pub fields: &'a [String],
pub summary: bool,
pub limit: u32,
@@ -23,9 +22,9 @@ pub struct SearchArgs<'a> {
}
pub async fn run(pool: &PgPool, args: SearchArgs<'_>) -> Result<()> {
validate_safe_search_args(args.show_secrets, args.fields)?;
validate_safe_search_args(args.fields)?;
let rows = fetch_rows_paged(
let rows = fetch_entries_paged(
pool,
PagedFetchArgs {
namespace: args.namespace,
@@ -40,14 +39,25 @@ pub async fn run(pool: &PgPool, args: SearchArgs<'_>) -> Result<()> {
)
.await?;
// -f/--field: extract specific field values directly
// -f/--field: extract specific metadata field values directly
if !args.fields.is_empty() {
return print_fields(&rows, args.fields);
}
// Fetch secret schemas for all returned entries (no master key needed).
let entry_ids: Vec<uuid::Uuid> = rows.iter().map(|r| r.id).collect();
let schema_map = if !args.summary && !entry_ids.is_empty() {
fetch_secret_schemas(pool, &entry_ids).await?
} else {
HashMap::new()
};
match args.output {
OutputMode::Json | OutputMode::JsonCompact => {
let arr: Vec<Value> = rows.iter().map(|r| to_json(r, args.summary)).collect();
let arr: Vec<Value> = rows
.iter()
.map(|r| to_json(r, args.summary, schema_map.get(&r.id).map(Vec::as_slice)))
.collect();
let out = if args.output == OutputMode::Json {
serde_json::to_string_pretty(&arr)?
} else {
@@ -61,7 +71,11 @@ pub async fn run(pool: &PgPool, args: SearchArgs<'_>) -> Result<()> {
return Ok(());
}
for row in &rows {
print_text(row, args.summary)?;
print_text(
row,
args.summary,
schema_map.get(&row.id).map(Vec::as_slice),
)?;
}
println!("{} record(s) found.", rows.len());
if rows.len() == args.limit as usize {
@@ -77,20 +91,13 @@ pub async fn run(pool: &PgPool, args: SearchArgs<'_>) -> Result<()> {
Ok(())
}
fn validate_safe_search_args(show_secrets: bool, fields: &[String]) -> Result<()> {
if show_secrets {
anyhow::bail!(
"`search` no longer reveals secrets. Use `secrets inject` or `secrets run` instead."
);
}
fn validate_safe_search_args(fields: &[String]) -> Result<()> {
if let Some(field) = fields.iter().find(|field| is_secret_field(field)) {
anyhow::bail!(
"Field '{}' is sensitive. `search -f` only supports metadata.* fields; use `secrets inject` or `secrets run` for secrets.",
field
);
}
Ok(())
}
@@ -101,16 +108,29 @@ fn is_secret_field(field: &str) -> bool {
)
}
/// Fetch rows with simple equality/tag filters (no pagination). Used by inject/run.
pub async fn fetch_rows(
// ── Entry fetching ────────────────────────────────────────────────────────────
struct PagedFetchArgs<'a> {
namespace: Option<&'a str>,
kind: Option<&'a str>,
name: Option<&'a str>,
tags: &'a [String],
query: Option<&'a str>,
sort: &'a str,
limit: u32,
offset: u32,
}
/// Fetch entries matching the given filters (used by search, inject, run).
pub async fn fetch_entries(
pool: &PgPool,
namespace: Option<&str>,
kind: Option<&str>,
name: Option<&str>,
tags: &[String],
query: Option<&str>,
) -> Result<Vec<Secret>> {
fetch_rows_paged(
) -> Result<Vec<Entry>> {
fetch_entries_paged(
pool,
PagedFetchArgs {
namespace,
@@ -126,19 +146,7 @@ pub async fn fetch_rows(
.await
}
/// Arguments for the internal paged fetch. Grouped to avoid too-many-arguments lint.
struct PagedFetchArgs<'a> {
namespace: Option<&'a str>,
kind: Option<&'a str>,
name: Option<&'a str>,
tags: &'a [String],
query: Option<&'a str>,
sort: &'a str,
limit: u32,
offset: u32,
}
async fn fetch_rows_paged(pool: &PgPool, a: PagedFetchArgs<'_>) -> Result<Vec<Secret>> {
async fn fetch_entries_paged(pool: &PgPool, a: PagedFetchArgs<'_>) -> Result<Vec<Entry>> {
let mut conditions: Vec<String> = Vec::new();
let mut idx: i32 = 1;
@@ -187,7 +195,7 @@ async fn fetch_rows_paged(pool: &PgPool, a: PagedFetchArgs<'_>) -> Result<Vec<Se
};
let sql = format!(
"SELECT * FROM secrets {} ORDER BY {} LIMIT ${} OFFSET ${}",
"SELECT * FROM entries {} ORDER BY {} LIMIT ${} OFFSET ${}",
where_clause,
order,
idx,
@@ -196,7 +204,7 @@ async fn fetch_rows_paged(pool: &PgPool, a: PagedFetchArgs<'_>) -> Result<Vec<Se
tracing::debug!(sql, "executing search query");
let mut q = sqlx::query_as::<_, Secret>(&sql);
let mut q = sqlx::query_as::<_, Entry>(&sql);
if let Some(v) = a.namespace {
q = q.bind(v);
}
@@ -219,12 +227,62 @@ async fn fetch_rows_paged(pool: &PgPool, a: PagedFetchArgs<'_>) -> Result<Vec<Se
}
q = q.bind(a.limit as i64).bind(a.offset as i64);
let rows = q.fetch_all(pool).await?;
Ok(rows)
Ok(q.fetch_all(pool).await?)
}
fn env_prefix(row: &Secret, prefix: &str) -> String {
let name_part = row.name.to_uppercase().replace(['-', '.', ' '], "_");
// ── Secret schema fetching (no master key) ───────────────────────────────────
/// Fetch secret field schemas (field_name, field_type, value_len) for a set of entry ids.
/// Returns a map from entry_id to list of SecretField (encrypted field not used here).
async fn fetch_secret_schemas(
pool: &PgPool,
entry_ids: &[uuid::Uuid],
) -> Result<HashMap<uuid::Uuid, Vec<SecretField>>> {
if entry_ids.is_empty() {
return Ok(HashMap::new());
}
let fields: Vec<SecretField> = sqlx::query_as(
"SELECT * FROM secrets WHERE entry_id = ANY($1) ORDER BY entry_id, field_name",
)
.bind(entry_ids)
.fetch_all(pool)
.await?;
let mut map: HashMap<uuid::Uuid, Vec<SecretField>> = HashMap::new();
for f in fields {
map.entry(f.entry_id).or_default().push(f);
}
Ok(map)
}
/// Fetch all secret fields (including encrypted bytes) for a set of entry ids.
pub async fn fetch_secrets_for_entries(
pool: &PgPool,
entry_ids: &[uuid::Uuid],
) -> Result<HashMap<uuid::Uuid, Vec<SecretField>>> {
if entry_ids.is_empty() {
return Ok(HashMap::new());
}
let fields: Vec<SecretField> = sqlx::query_as(
"SELECT * FROM secrets WHERE entry_id = ANY($1) ORDER BY entry_id, field_name",
)
.bind(entry_ids)
.fetch_all(pool)
.await?;
let mut map: HashMap<uuid::Uuid, Vec<SecretField>> = HashMap::new();
for f in fields {
map.entry(f.entry_id).or_default().push(f);
}
Ok(map)
}
// ── Display helpers ───────────────────────────────────────────────────────────
fn env_prefix(entry: &Entry, prefix: &str) -> String {
let name_part = entry.name.to_uppercase().replace(['-', '.', ' '], "_");
if prefix.is_empty() {
name_part
} else {
@@ -236,15 +294,12 @@ fn env_prefix(row: &Secret, prefix: &str) -> String {
}
}
/// Build a flat `KEY=VALUE` map from metadata only.
/// Variable names: `<PREFIX><NAME>_<FIELD>` (all uppercased, hyphens/dots → underscores).
/// If `prefix` is empty, the name segment alone is used as the prefix.
pub fn build_metadata_env_map(row: &Secret, prefix: &str) -> HashMap<String, String> {
let effective_prefix = env_prefix(row, prefix);
/// Build a flat KEY=VALUE map from metadata only (no master key required).
pub fn build_metadata_env_map(entry: &Entry, prefix: &str) -> HashMap<String, String> {
let effective_prefix = env_prefix(entry, prefix);
let mut map = HashMap::new();
if let Some(meta) = row.metadata.as_object() {
if let Some(meta) = entry.metadata.as_object() {
for (k, v) in meta {
let key = format!(
"{}_{}",
@@ -254,37 +309,68 @@ pub fn build_metadata_env_map(row: &Secret, prefix: &str) -> HashMap<String, Str
map.insert(key, json_value_to_env_string(v));
}
}
map
}
/// Build a flat `KEY=VALUE` map from metadata and decrypted secrets.
pub fn build_injected_env_map(
row: &Secret,
/// Build a flat KEY=VALUE map from metadata + decrypted secret fields.
/// Resolves key_ref: if metadata.key_ref is set, merges secret fields from that key entry.
pub async fn build_injected_env_map(
pool: &PgPool,
entry: &Entry,
prefix: &str,
master_key: &[u8; 32],
fields: &[SecretField],
) -> Result<HashMap<String, String>> {
let effective_prefix = env_prefix(row, prefix);
let mut map = build_metadata_env_map(row, prefix);
let effective_prefix = env_prefix(entry, prefix);
let mut map = build_metadata_env_map(entry, prefix);
if !row.encrypted.is_empty() {
let decrypted = crypto::decrypt_json(master_key, &row.encrypted)?;
if let Some(enc) = decrypted.as_object() {
for (k, v) in enc {
let key = format!(
// Decrypt each secret field and add to env map.
for f in fields {
let decrypted = crypto::decrypt_json(master_key, &f.encrypted)?;
let key = format!(
"{}_{}",
effective_prefix,
f.field_name.to_uppercase().replace(['-', '.'], "_")
);
map.insert(key, json_value_to_env_string(&decrypted));
}
// Resolve key_ref: merge secrets from the referenced key entry.
if let Some(key_ref) = entry.metadata.get("key_ref").and_then(|v| v.as_str()) {
let key_entries = fetch_entries(
pool,
Some(&entry.namespace),
Some("key"),
Some(key_ref),
&[],
None,
)
.await?;
if let Some(key_entry) = key_entries.first() {
let key_ids = vec![key_entry.id];
let key_fields_map = fetch_secrets_for_entries(pool, &key_ids).await?;
let empty = vec![];
let key_fields = key_fields_map.get(&key_entry.id).unwrap_or(&empty);
let key_prefix = env_prefix(key_entry, prefix);
for f in key_fields {
let decrypted = crypto::decrypt_json(master_key, &f.encrypted)?;
let key_var = format!(
"{}_{}",
effective_prefix,
k.to_uppercase().replace(['-', '.'], "_")
key_prefix,
f.field_name.to_uppercase().replace(['-', '.'], "_")
);
map.insert(key, json_value_to_env_string(v));
map.insert(key_var, json_value_to_env_string(&decrypted));
}
} else {
tracing::warn!(key_ref, "key_ref target not found");
}
}
Ok(map)
}
/// Convert a JSON value to its string representation suitable for env vars.
fn json_value_to_env_string(v: &Value) -> String {
match v {
Value::String(s) => s.clone(),
@@ -293,81 +379,101 @@ fn json_value_to_env_string(v: &Value) -> String {
}
}
fn to_json(row: &Secret, summary: bool) -> Value {
fn to_json(entry: &Entry, summary: bool, schema: Option<&[SecretField]>) -> Value {
if summary {
let desc = row
let desc = entry
.metadata
.get("desc")
.or_else(|| row.metadata.get("url"))
.or_else(|| entry.metadata.get("url"))
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
return json!({
"namespace": row.namespace,
"kind": row.kind,
"name": row.name,
"tags": row.tags,
"namespace": entry.namespace,
"kind": entry.kind,
"name": entry.name,
"tags": entry.tags,
"desc": desc,
"updated_at": row.updated_at.format("%Y-%m-%dT%H:%M:%SZ").to_string(),
"updated_at": entry.updated_at.format("%Y-%m-%dT%H:%M:%SZ").to_string(),
});
}
let secrets_val = if row.encrypted.is_empty() {
Value::Object(Default::default())
} else {
json!({"_encrypted": true})
let secrets_val: Value = match schema {
Some(fields) if !fields.is_empty() => {
let schema_arr: Vec<Value> = fields
.iter()
.map(|f| {
json!({
"field_name": f.field_name,
"field_type": f.field_type,
"value_len": f.value_len,
})
})
.collect();
Value::Array(schema_arr)
}
_ => Value::Array(vec![]),
};
json!({
"id": row.id,
"namespace": row.namespace,
"kind": row.kind,
"name": row.name,
"tags": row.tags,
"metadata": row.metadata,
"id": entry.id,
"namespace": entry.namespace,
"kind": entry.kind,
"name": entry.name,
"tags": entry.tags,
"metadata": entry.metadata,
"secrets": secrets_val,
"version": row.version,
"created_at": row.created_at.format("%Y-%m-%dT%H:%M:%SZ").to_string(),
"updated_at": row.updated_at.format("%Y-%m-%dT%H:%M:%SZ").to_string(),
"version": entry.version,
"created_at": entry.created_at.format("%Y-%m-%dT%H:%M:%SZ").to_string(),
"updated_at": entry.updated_at.format("%Y-%m-%dT%H:%M:%SZ").to_string(),
})
}
fn print_text(row: &Secret, summary: bool) -> Result<()> {
println!("[{}/{}] {}", row.namespace, row.kind, row.name);
fn print_text(entry: &Entry, summary: bool, schema: Option<&[SecretField]>) -> Result<()> {
println!("[{}/{}] {}", entry.namespace, entry.kind, entry.name);
if summary {
let desc = row
let desc = entry
.metadata
.get("desc")
.or_else(|| row.metadata.get("url"))
.or_else(|| entry.metadata.get("url"))
.and_then(|v| v.as_str())
.unwrap_or("-");
if !row.tags.is_empty() {
println!(" tags: [{}]", row.tags.join(", "));
if !entry.tags.is_empty() {
println!(" tags: [{}]", entry.tags.join(", "));
}
println!(" desc: {}", desc);
println!(" updated: {}", format_local_time(row.updated_at));
println!(" updated: {}", format_local_time(entry.updated_at));
} else {
println!(" id: {}", row.id);
if !row.tags.is_empty() {
println!(" tags: [{}]", row.tags.join(", "));
println!(" id: {}", entry.id);
if !entry.tags.is_empty() {
println!(" tags: [{}]", entry.tags.join(", "));
}
if row.metadata.as_object().is_some_and(|m| !m.is_empty()) {
if entry.metadata.as_object().is_some_and(|m| !m.is_empty()) {
println!(
" metadata: {}",
serde_json::to_string_pretty(&row.metadata)?
serde_json::to_string_pretty(&entry.metadata)?
);
}
if !row.encrypted.is_empty() {
println!(" secrets: [encrypted] (use `secrets inject` or `secrets run`)");
match schema {
Some(fields) if !fields.is_empty() => {
let schema_str: Vec<String> = fields
.iter()
.map(|f| format!("{}: {}({})", f.field_name, f.field_type, f.value_len))
.collect();
println!(" secrets: {}", schema_str.join(", "));
println!(" (use `secrets inject` or `secrets run` to get values)");
}
_ => {}
}
println!(" created: {}", format_local_time(row.created_at));
println!(" version: {}", entry.version);
println!(" created: {}", format_local_time(entry.created_at));
}
println!();
Ok(())
}
/// Extract one or more field paths like `metadata.url`.
fn print_fields(rows: &[Secret], fields: &[String]) -> Result<()> {
/// Extract one or more metadata field paths like `metadata.url`.
fn print_fields(rows: &[Entry], fields: &[String]) -> Result<()> {
for row in rows {
for field in fields {
let val = extract_field(row, field)?;
@@ -377,13 +483,13 @@ fn print_fields(rows: &[Secret], fields: &[String]) -> Result<()> {
Ok(())
}
fn extract_field(row: &Secret, field: &str) -> Result<String> {
fn extract_field(entry: &Entry, field: &str) -> Result<String> {
let (section, key) = field
.split_once('.')
.ok_or_else(|| anyhow::anyhow!("Invalid field path '{}'. Use metadata.<key>.", field))?;
let obj = match section {
"metadata" | "meta" => &row.metadata,
"metadata" | "meta" => &entry.metadata,
other => anyhow::bail!("Unknown field section '{}'. Use 'metadata'.", other),
};
@@ -397,9 +503,9 @@ fn extract_field(row: &Secret, field: &str) -> Result<String> {
anyhow::anyhow!(
"Field '{}' not found in record [{}/{}/{}]",
field,
row.namespace,
row.kind,
row.name
entry.namespace,
entry.kind,
entry.name
)
})
}
@@ -411,41 +517,47 @@ mod tests {
use serde_json::json;
use uuid::Uuid;
fn sample_secret() -> Secret {
let key = [0x42u8; 32];
let encrypted = crypto::encrypt_json(&key, &json!({"token": "abc123"})).unwrap();
Secret {
fn sample_entry() -> Entry {
Entry {
id: Uuid::nil(),
namespace: "refining".to_string(),
kind: "service".to_string(),
name: "gitea.main".to_string(),
tags: vec!["prod".to_string()],
metadata: json!({"url": "https://gitea.refining.dev", "enabled": true}),
encrypted,
version: 1,
created_at: Utc::now(),
updated_at: Utc::now(),
}
}
#[test]
fn rejects_show_secrets_flag() {
let err = validate_safe_search_args(true, &[]).unwrap_err();
assert!(err.to_string().contains("no longer reveals secrets"));
fn sample_fields() -> Vec<SecretField> {
let key = [0x42u8; 32];
let enc = crypto::encrypt_json(&key, &json!("abc123")).unwrap();
vec![SecretField {
id: Uuid::nil(),
entry_id: Uuid::nil(),
field_name: "token".to_string(),
field_type: "string".to_string(),
value_len: 6,
encrypted: enc,
version: 1,
created_at: Utc::now(),
updated_at: Utc::now(),
}]
}
#[test]
fn rejects_secret_field_extraction() {
let fields = vec!["secret.token".to_string()];
let err = validate_safe_search_args(false, &fields).unwrap_err();
let err = validate_safe_search_args(&fields).unwrap_err();
assert!(err.to_string().contains("sensitive"));
}
#[test]
fn metadata_env_map_excludes_secret_values() {
let row = sample_secret();
let map = build_metadata_env_map(&row, "");
let entry = sample_entry();
let map = build_metadata_env_map(&entry, "");
assert_eq!(
map.get("GITEA_MAIN_URL").map(String::as_str),
@@ -459,14 +571,23 @@ mod tests {
}
#[test]
fn injected_env_map_includes_secret_values() {
let row = sample_secret();
let key = [0x42u8; 32];
let map = build_injected_env_map(&row, "", &key).unwrap();
fn to_json_full_includes_secrets_schema() {
let entry = sample_entry();
let fields = sample_fields();
let v = to_json(&entry, false, Some(&fields));
assert_eq!(
map.get("GITEA_MAIN_TOKEN").map(String::as_str),
Some("abc123")
);
let secrets = v.get("secrets").unwrap().as_array().unwrap();
assert_eq!(secrets.len(), 1);
assert_eq!(secrets[0]["field_name"], "token");
assert_eq!(secrets[0]["field_type"], "string");
assert_eq!(secrets[0]["value_len"], 6);
}
#[test]
fn to_json_summary_omits_secrets_schema() {
let entry = sample_entry();
let fields = sample_fields();
let v = to_json(&entry, true, Some(&fields));
assert!(v.get("secrets").is_none());
}
}