Skip to main content

ironflow_api/routes/secrets/
list.rs

1//! `GET /api/v1/secrets` -- List secrets (admin only, never exposes values).
2
3use axum::extract::{Query, State};
4use axum::response::IntoResponse;
5use serde::Deserialize;
6
7use ironflow_auth::extractor::Authenticated;
8
9use crate::entities::SecretResponse;
10use crate::error::ApiError;
11use crate::response::ok_paged;
12use crate::state::AppState;
13
14/// Query parameters for listing secrets.
15#[cfg_attr(feature = "openapi", derive(utoipa::IntoParams))]
16#[derive(Debug, Deserialize)]
17pub struct ListSecretsQuery {
18    /// Filter by key prefix (e.g. `workflows/inbox/`).
19    pub prefix: Option<String>,
20    /// Page number (1-based, default 1).
21    pub page: Option<u32>,
22    /// Items per page (default 50, max 100).
23    pub per_page: Option<u32>,
24}
25
26/// List secret metadata. Admin only.
27///
28/// Returns key, id, and timestamps. Never returns encrypted or decrypted values.
29#[cfg_attr(
30    feature = "openapi",
31    utoipa::path(
32        get,
33        path = "/api/v1/secrets",
34        tags = ["secrets"],
35        params(ListSecretsQuery),
36        responses(
37            (status = 200, description = "Secrets listed", body = Vec<SecretResponse>),
38            (status = 401, description = "Unauthorized"),
39            (status = 403, description = "Forbidden")
40        ),
41        security(("Bearer" = []))
42    )
43)]
44pub async fn list_secrets(
45    auth: Authenticated,
46    State(state): State<AppState>,
47    Query(query): Query<ListSecretsQuery>,
48) -> Result<impl IntoResponse, ApiError> {
49    if !auth.is_admin() {
50        return Err(ApiError::Forbidden);
51    }
52
53    let prefix = query.prefix.as_deref().unwrap_or("");
54    let page = query.page.unwrap_or(1).max(1);
55    let per_page = query.per_page.unwrap_or(50).clamp(1, 100);
56
57    let result = state.store.list_secrets(prefix, page, per_page).await?;
58
59    let data: Vec<SecretResponse> = result.items.into_iter().map(SecretResponse::from).collect();
60
61    Ok(ok_paged(data, result.page, result.per_page, result.total))
62}
63
64#[cfg(test)]
65mod tests {
66    use axum::Router;
67    use axum::body::Body;
68    use axum::http::{Request, StatusCode};
69    use axum::routing::get;
70    use http_body_util::BodyExt;
71    use ironflow_auth::jwt::{AccessToken, JwtConfig};
72    use ironflow_core::providers::claude::ClaudeCodeProvider;
73    use ironflow_engine::context::WorkflowContext;
74    use ironflow_engine::engine::Engine;
75    use ironflow_engine::handler::{HandlerFuture, WorkflowHandler};
76    use ironflow_engine::notify::Event;
77    use ironflow_store::memory::InMemoryStore;
78    use ironflow_store::store::Store;
79    use serde_json::Value as JsonValue;
80    use std::sync::Arc;
81    use tokio::sync::broadcast;
82    use tower::ServiceExt;
83    use uuid::Uuid;
84
85    use super::*;
86
87    struct TestWorkflow;
88
89    impl WorkflowHandler for TestWorkflow {
90        fn name(&self) -> &str {
91            "test-workflow"
92        }
93
94        fn execute<'a>(&'a self, _ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
95            Box::pin(async move { Ok(()) })
96        }
97    }
98
99    fn test_jwt_config() -> Arc<JwtConfig> {
100        Arc::new(JwtConfig {
101            secret: "test-secret".to_string(),
102            access_token_ttl_secs: 900,
103            refresh_token_ttl_secs: 604800,
104            cookie_domain: None,
105            cookie_secure: false,
106        })
107    }
108
109    fn test_state() -> AppState {
110        let store: Arc<dyn Store> = Arc::new(InMemoryStore::new());
111        let provider = Arc::new(ClaudeCodeProvider::new());
112        let mut engine = Engine::new(store.clone(), provider);
113        engine.register(TestWorkflow).unwrap();
114        let (event_sender, _) = broadcast::channel::<Event>(1);
115        AppState::new(
116            store,
117            Arc::new(engine),
118            test_jwt_config(),
119            "test-worker-token".to_string(),
120            event_sender,
121        )
122    }
123
124    fn make_admin_token(state: &AppState) -> String {
125        let user_id = Uuid::now_v7();
126        let token = AccessToken::for_user(user_id, "admin", true, &state.jwt_config).unwrap();
127        format!("Bearer {}", token.0)
128    }
129
130    fn make_regular_token(state: &AppState) -> String {
131        let user_id = Uuid::now_v7();
132        let token = AccessToken::for_user(user_id, "user", false, &state.jwt_config).unwrap();
133        format!("Bearer {}", token.0)
134    }
135
136    #[tokio::test]
137    async fn list_secrets_admin_only() {
138        let state = test_state();
139        let auth_header = make_regular_token(&state);
140
141        let app = Router::new()
142            .route("/", get(list_secrets))
143            .with_state(state);
144
145        let req = Request::builder()
146            .uri("/")
147            .method("GET")
148            .header("authorization", auth_header)
149            .body(Body::empty())
150            .unwrap();
151
152        let resp = app.oneshot(req).await.unwrap();
153        assert_eq!(resp.status(), StatusCode::FORBIDDEN);
154    }
155
156    #[tokio::test]
157    async fn list_secrets_admin_succeeds() {
158        let state = test_state();
159        let auth_header = make_admin_token(&state);
160
161        let app = Router::new()
162            .route("/", get(list_secrets))
163            .with_state(state);
164
165        let req = Request::builder()
166            .uri("/")
167            .method("GET")
168            .header("authorization", auth_header)
169            .body(Body::empty())
170            .unwrap();
171
172        let resp = app.oneshot(req).await.unwrap();
173        assert_eq!(resp.status(), StatusCode::OK);
174
175        let body = resp.into_body().collect().await.unwrap().to_bytes();
176        let json_val: JsonValue = serde_json::from_slice(&body).unwrap();
177        assert!(json_val["data"].is_array());
178        assert!(json_val["meta"].is_object());
179    }
180
181    #[tokio::test]
182    async fn list_secrets_includes_pagination() {
183        let state = test_state();
184        let auth_header = make_admin_token(&state);
185
186        let app = Router::new()
187            .route("/", get(list_secrets))
188            .with_state(state);
189
190        let req = Request::builder()
191            .uri("/?page=1&per_page=10")
192            .method("GET")
193            .header("authorization", auth_header)
194            .body(Body::empty())
195            .unwrap();
196
197        let resp = app.oneshot(req).await.unwrap();
198        assert_eq!(resp.status(), StatusCode::OK);
199
200        let body = resp.into_body().collect().await.unwrap().to_bytes();
201        let json_val: JsonValue = serde_json::from_slice(&body).unwrap();
202        assert_eq!(json_val["meta"]["page"], 1);
203        assert_eq!(json_val["meta"]["per_page"], 10);
204    }
205
206    #[tokio::test]
207    async fn list_secrets_clamps_per_page_max() {
208        let state = test_state();
209        let auth_header = make_admin_token(&state);
210
211        let app = Router::new()
212            .route("/", get(list_secrets))
213            .with_state(state);
214
215        let req = Request::builder()
216            .uri("/?per_page=500")
217            .method("GET")
218            .header("authorization", auth_header)
219            .body(Body::empty())
220            .unwrap();
221
222        let resp = app.oneshot(req).await.unwrap();
223        assert_eq!(resp.status(), StatusCode::OK);
224
225        let body = resp.into_body().collect().await.unwrap().to_bytes();
226        let json_val: JsonValue = serde_json::from_slice(&body).unwrap();
227        assert_eq!(json_val["meta"]["per_page"], 100);
228    }
229}