diff --git a/Cargo.lock b/Cargo.lock index 679237f..34159e7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -764,6 +764,25 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "secrets-service" +version = "0.1.0" +dependencies = [ + "anyhow", + "llm-multiverse-proto", + "prost", + "prost-types", + "serde", + "tempfile", + "thiserror", + "tokio", + "tokio-stream", + "toml", + "tonic", + "tracing", + "tracing-subscriber", +] + [[package]] name = "semver" version = "1.0.27" diff --git a/Cargo.toml b/Cargo.toml index e52d88f..0c5ccd7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,4 +3,5 @@ resolver = "2" members = [ "gen/rust", "services/audit", + "services/secrets", ] diff --git a/implementation-plans/_index.md b/implementation-plans/_index.md index 7b5de72..72f3dd2 100644 --- a/implementation-plans/_index.md +++ b/implementation-plans/_index.md @@ -19,6 +19,7 @@ | #19 | Implement append-only file log backend | Phase 2 | `COMPLETED` | Rust | [issue-019.md](issue-019.md) | | #20 | Implement Append gRPC endpoint | Phase 2 | `COMPLETED` | Rust | [issue-020.md](issue-020.md) | | #21 | Integration tests for Audit Service | Phase 2 | `COMPLETED` | Rust | [issue-021.md](issue-021.md) | +| #22 | Scaffold Secrets Service Rust project | Phase 3 | `COMPLETED` | Rust | [issue-022.md](issue-022.md) | ## Status Legend @@ -39,6 +40,7 @@ ### Secrets Service - [issue-010.md](issue-010.md) — secrets.proto (SecretsService, GetSecret) +- [issue-022.md](issue-022.md) — Scaffold Secrets Service Rust project ### Memory Service - [issue-011.md](issue-011.md) — memory.proto (MemoryService, MemoryEntry) diff --git a/implementation-plans/issue-022.md b/implementation-plans/issue-022.md new file mode 100644 index 0000000..b4d0a22 --- /dev/null +++ b/implementation-plans/issue-022.md @@ -0,0 +1,41 @@ +# Implementation Plan — Issue #22: Scaffold Secrets Service Rust project + +## Metadata + +| Field | Value | +|---|---| +| Issue | [#22](https://git.shahondin1624.de/llm-multiverse/llm-multiverse/issues/22) | +| Title | Scaffold Secrets Service Rust project | +| Milestone | Phase 3: Secrets Service | +| Labels | `type:infrastructure`, `priority:high`, `lang:rust`, `service:secrets` | +| Status | `COMPLETED` | +| Language | Rust | +| Related Plans | [issue-018.md](issue-018.md) | +| Blocked by | #16 (completed) | + +## Acceptance Criteria + +- [x] Cargo workspace member created (`services/secrets/`) +- [x] Dependency on proto-gen crate +- [x] Tonic gRPC server boilerplate compiles +- [x] Configuration loading (address, port, backend selection) +- [x] Stub GetSecret endpoint with input validation + +## Architecture Analysis + +Follows the same pattern as audit service scaffold (#18). Config supports `BackendType` enum (libsecret, keyring) for future backend implementations (#23, #24). Stub service validates inputs and returns Unimplemented until backends are wired in. + +## Files to Create/Modify + +| File | Action | Purpose | +|---|---|---| +| `Cargo.toml` | Modify | Add services/secrets to workspace members | +| `services/secrets/Cargo.toml` | Create | Secrets service crate | +| `services/secrets/src/lib.rs` | Create | Module re-exports | +| `services/secrets/src/main.rs` | Create | Server entry point | +| `services/secrets/src/config.rs` | Create | Configuration with backend selection | +| `services/secrets/src/service.rs` | Create | SecretsService stub with validation | + +## Deviation Log + +_(No deviations)_ diff --git a/services/secrets/Cargo.toml b/services/secrets/Cargo.toml new file mode 100644 index 0000000..4fded8d --- /dev/null +++ b/services/secrets/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "secrets-service" +version = "0.1.0" +edition = "2021" +description = "Credential retrieval service for llm-multiverse" +publish = false + +[dependencies] +llm-multiverse-proto = { path = "../../gen/rust" } +tonic = "0.13" +prost = "0.13" +prost-types = "0.13" +tokio = { version = "1", features = ["full"] } +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +serde = { version = "1", features = ["derive"] } +toml = "0.8" +anyhow = "1" +thiserror = "2" + +[dev-dependencies] +tempfile = "3" +tonic = { version = "0.13", features = ["transport"] } +tokio-stream = "0.1" diff --git a/services/secrets/src/config.rs b/services/secrets/src/config.rs new file mode 100644 index 0000000..79c0d07 --- /dev/null +++ b/services/secrets/src/config.rs @@ -0,0 +1,118 @@ +use serde::Deserialize; + +/// Backend type for secret retrieval. +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum BackendType { + /// Linux Secret Service API via D-Bus (libsecret). + Libsecret, + /// Linux kernel keyring. + Keyring, +} + +fn default_backend() -> BackendType { + BackendType::Libsecret +} + +#[derive(Debug, Clone, Deserialize)] +pub struct Config { + #[serde(default = "default_host")] + pub host: String, + #[serde(default = "default_port")] + pub port: u16, + #[serde(default = "default_backend")] + pub backend: BackendType, +} + +fn default_host() -> String { + "[::1]".to_string() +} + +fn default_port() -> u16 { + 50053 +} + +impl Default for Config { + fn default() -> Self { + Self { + host: default_host(), + port: default_port(), + backend: default_backend(), + } + } +} + +impl Config { + /// Load config from file, falling back to defaults. + pub fn load(path: Option<&str>) -> anyhow::Result { + match path { + Some(p) => { + let contents = std::fs::read_to_string(p)?; + let config: Config = toml::from_str(&contents)?; + Ok(config) + } + None => Ok(Self::default()), + } + } + + pub fn listen_addr(&self) -> String { + format!("{}:{}", self.host, self.port) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_default_config() { + let config = Config::default(); + assert_eq!(config.host, "[::1]"); + assert_eq!(config.port, 50053); + assert_eq!(config.backend, BackendType::Libsecret); + } + + #[test] + fn test_listen_addr() { + let config = Config::default(); + assert_eq!(config.listen_addr(), "[::1]:50053"); + } + + #[test] + fn test_load_from_toml() { + let dir = tempfile::tempdir().unwrap(); + let config_path = dir.path().join("secrets.toml"); + std::fs::write( + &config_path, + r#" +host = "0.0.0.0" +port = 9999 +backend = "keyring" +"#, + ) + .unwrap(); + + let config = Config::load(Some(config_path.to_str().unwrap())).unwrap(); + assert_eq!(config.host, "0.0.0.0"); + assert_eq!(config.port, 9999); + assert_eq!(config.backend, BackendType::Keyring); + } + + #[test] + fn test_load_no_file_uses_defaults() { + let config = Config::load(None).unwrap(); + assert_eq!(config.port, 50053); + assert_eq!(config.backend, BackendType::Libsecret); + } + + #[test] + fn test_backend_deserialization() { + let toml_str = r#"backend = "libsecret""#; + let config: Config = toml::from_str(toml_str).unwrap(); + assert_eq!(config.backend, BackendType::Libsecret); + + let toml_str = r#"backend = "keyring""#; + let config: Config = toml::from_str(toml_str).unwrap(); + assert_eq!(config.backend, BackendType::Keyring); + } +} diff --git a/services/secrets/src/lib.rs b/services/secrets/src/lib.rs new file mode 100644 index 0000000..11bd8fe --- /dev/null +++ b/services/secrets/src/lib.rs @@ -0,0 +1,2 @@ +pub mod config; +pub mod service; diff --git a/services/secrets/src/main.rs b/services/secrets/src/main.rs new file mode 100644 index 0000000..a4eee6c --- /dev/null +++ b/services/secrets/src/main.rs @@ -0,0 +1,44 @@ +use llm_multiverse_proto::llm_multiverse::v1::secrets_service_server::SecretsServiceServer; +use secrets_service::config::Config; +use secrets_service::service::SecretsServiceImpl; +use tonic::transport::Server; +use tracing_subscriber::EnvFilter; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + tracing_subscriber::fmt() + .with_env_filter( + EnvFilter::from_default_env() + .add_directive("secrets_service=info".parse()?), + ) + .init(); + + let config_path = std::env::var("SECRETS_CONFIG").ok(); + let config = Config::load(config_path.as_deref())?; + + tracing::info!( + addr = %config.listen_addr(), + backend = ?config.backend, + "Starting Secrets Service" + ); + + let addr = config.listen_addr().parse()?; + let secrets_service = SecretsServiceImpl::new(); + + tracing::info!(%addr, "Secrets Service listening"); + + Server::builder() + .add_service(SecretsServiceServer::new(secrets_service)) + .serve_with_shutdown(addr, shutdown_signal()) + .await?; + + tracing::info!("Secrets Service shut down gracefully"); + Ok(()) +} + +async fn shutdown_signal() { + tokio::signal::ctrl_c() + .await + .expect("failed to listen for ctrl+c"); + tracing::info!("Shutdown signal received"); +} diff --git a/services/secrets/src/service.rs b/services/secrets/src/service.rs new file mode 100644 index 0000000..d910bb0 --- /dev/null +++ b/services/secrets/src/service.rs @@ -0,0 +1,118 @@ +use llm_multiverse_proto::llm_multiverse::v1::{ + secrets_service_server::SecretsService, GetSecretRequest, GetSecretResponse, +}; +use tonic::{Request, Response, Status}; + +/// Secrets service implementation. +/// Currently a stub — backend implementations (#23, #24) will be wired in later. +pub struct SecretsServiceImpl; + +impl Default for SecretsServiceImpl { + fn default() -> Self { + Self + } +} + +impl SecretsServiceImpl { + pub fn new() -> Self { + Self + } +} + +#[tonic::async_trait] +impl SecretsService for SecretsServiceImpl { + async fn get_secret( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + + // Validate session context. + let ctx = req + .context + .ok_or_else(|| Status::invalid_argument("session context is required"))?; + if ctx.session_id.is_empty() { + return Err(Status::invalid_argument("context.session_id is required")); + } + + // Validate secret name. + if req.secret_name.is_empty() { + return Err(Status::invalid_argument("secret_name is required")); + } + + tracing::debug!( + session_id = %ctx.session_id, + secret_name = %req.secret_name, + "GetSecret requested" + ); + + // Backend not yet implemented — return unimplemented. + Err(Status::unimplemented( + "secret backend not yet implemented (see issues #23, #24)", + )) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use llm_multiverse_proto::llm_multiverse::v1::SessionContext; + + fn make_request( + context: Option, + secret_name: &str, + ) -> Request { + Request::new(GetSecretRequest { + context, + secret_name: secret_name.into(), + }) + } + + #[tokio::test] + async fn test_rejects_missing_context() { + let svc = SecretsServiceImpl::new(); + let result = svc.get_secret(make_request(None, "api_key")).await; + assert!(result.is_err()); + assert_eq!(result.unwrap_err().code(), tonic::Code::InvalidArgument); + } + + #[tokio::test] + async fn test_rejects_empty_session_id() { + let svc = SecretsServiceImpl::new(); + let ctx = SessionContext { + session_id: "".into(), + ..Default::default() + }; + let result = svc.get_secret(make_request(Some(ctx), "api_key")).await; + assert!(result.is_err()); + let status = result.unwrap_err(); + assert_eq!(status.code(), tonic::Code::InvalidArgument); + assert!(status.message().contains("context.session_id")); + } + + #[tokio::test] + async fn test_rejects_empty_secret_name() { + let svc = SecretsServiceImpl::new(); + let ctx = SessionContext { + session_id: "sess-1".into(), + ..Default::default() + }; + let result = svc.get_secret(make_request(Some(ctx), "")).await; + assert!(result.is_err()); + let status = result.unwrap_err(); + assert_eq!(status.code(), tonic::Code::InvalidArgument); + assert!(status.message().contains("secret_name")); + } + + #[tokio::test] + async fn test_valid_request_returns_unimplemented() { + let svc = SecretsServiceImpl::new(); + let ctx = SessionContext { + session_id: "sess-1".into(), + ..Default::default() + }; + let result = svc.get_secret(make_request(Some(ctx), "api_key")).await; + assert!(result.is_err()); + assert_eq!(result.unwrap_err().code(), tonic::Code::Unimplemented); + } +}