Skip to main content

ironflow_api/routes/api_keys/
create.rs

1//! `POST /api/v1/api-keys` -- Create a new API key.
2
3use axum::Json;
4use axum::extract::State;
5use axum::http::StatusCode;
6use axum::response::IntoResponse;
7use chrono::{DateTime, Utc};
8use ironflow_auth::extractor::AuthenticatedUser;
9use ironflow_auth::password;
10use ironflow_store::entities::{ApiKeyScope, NewApiKey};
11use rand::Rng;
12use serde::{Deserialize, Serialize};
13use uuid::Uuid;
14
15use crate::error::ApiError;
16use crate::response::ok;
17use crate::state::AppState;
18use ironflow_auth::extractor::{API_KEY_PREFIX, API_KEY_SUFFIX_LEN};
19
20/// Maximum length of an API key name, in characters (matches the
21/// `iam.api_keys.name` VARCHAR(255) column).
22const MAX_API_KEY_NAME_LEN: usize = 255;
23
24/// Request body for creating an API key.
25#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
26#[derive(Debug, Deserialize)]
27pub struct CreateApiKeyRequest {
28    /// Human-readable name for this key.
29    pub name: String,
30    /// Scopes to grant.
31    pub scopes: Vec<ApiKeyScope>,
32    /// Optional expiration date (ISO 8601).
33    pub expires_at: Option<DateTime<Utc>>,
34    /// Optional per-key rate limit override (requests per minute).
35    /// When set, the server uses this value instead of the global rate
36    /// limit for requests authenticated with this key.
37    pub rate_limit_override: Option<u32>,
38}
39
40/// Response returned when creating an API key.
41/// The raw key is only shown once.
42#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
43#[derive(Debug, Serialize)]
44pub struct CreateApiKeyResponse {
45    /// API key ID.
46    pub id: Uuid,
47    /// The full raw API key (only returned at creation time).
48    pub key: String,
49    /// First characters for identification.
50    pub key_prefix: String,
51    /// Key name.
52    pub name: String,
53    /// Granted scopes.
54    pub scopes: Vec<ApiKeyScope>,
55    /// Expiration date.
56    pub expires_at: Option<DateTime<Utc>>,
57    /// Per-key rate limit override (requests per minute), if set.
58    pub rate_limit_override: Option<u32>,
59    /// Creation date.
60    pub created_at: DateTime<Utc>,
61}
62
63/// Create a new API key for the authenticated user.
64///
65/// # Errors
66///
67/// - 400 if the name is empty or longer than 255 characters (after trimming), or scopes are invalid
68#[cfg_attr(
69    feature = "openapi",
70    utoipa::path(
71        post,
72        path = "/api/v1/api-keys",
73        tags = ["api-keys"],
74        request_body(content = CreateApiKeyRequest, description = "API key configuration"),
75        responses(
76            (status = 201, description = "API key created successfully", body = CreateApiKeyResponse),
77            (status = 400, description = "Invalid input"),
78            (status = 401, description = "Unauthorized"),
79            (status = 403, description = "Forbidden (member assigning forbidden scopes or a rate_limit_override)")
80        ),
81        security(("Bearer" = []))
82    )
83)]
84pub async fn create_api_key(
85    user: AuthenticatedUser,
86    State(state): State<AppState>,
87    Json(req): Json<CreateApiKeyRequest>,
88) -> Result<impl IntoResponse, ApiError> {
89    let name = validate_name(&req.name)?.to_string();
90
91    if req.scopes.is_empty() {
92        return Err(ApiError::BadRequest(
93            "at least one scope is required".to_string(),
94        ));
95    }
96
97    if !user.is_admin && !ApiKeyScope::all_allowed_for_member(&req.scopes) {
98        return Err(ApiError::Forbidden);
99    }
100
101    // Only admins may lift their own key above the server-wide limit.
102    if !user.is_admin && req.rate_limit_override.is_some() {
103        return Err(ApiError::Forbidden);
104    }
105
106    if let Some(override_val) = req.rate_limit_override
107        && override_val > 10_000
108    {
109        return Err(ApiError::BadRequest(
110            "rate_limit_override must be between 0 and 10000".to_string(),
111        ));
112    }
113
114    let raw_key = generate_api_key();
115    let key_prefix = raw_key[..API_KEY_PREFIX.len() + API_KEY_SUFFIX_LEN].to_string();
116    let key_hash =
117        password::hash(&raw_key).map_err(|e| ApiError::Internal(format!("hashing: {e}")))?;
118
119    let api_key = state
120        .store
121        .create_api_key(NewApiKey {
122            user_id: user.user_id,
123            name,
124            key_hash,
125            key_prefix: key_prefix.clone(),
126            scopes: req.scopes,
127            expires_at: req.expires_at,
128            rate_limit_override: req.rate_limit_override,
129        })
130        .await
131        .map_err(ApiError::from)?;
132
133    let response = CreateApiKeyResponse {
134        id: api_key.id,
135        key: raw_key,
136        key_prefix,
137        name: api_key.name,
138        scopes: api_key.scopes,
139        expires_at: api_key.expires_at,
140        rate_limit_override: api_key.rate_limit_override,
141        created_at: api_key.created_at,
142    };
143
144    Ok((StatusCode::CREATED, ok(response)))
145}
146
147/// Trim `name` and check it is non-empty and within the column bound.
148///
149/// The trimmed value is the one to store, so the stored name always fits.
150/// Characters are counted, not bytes, as Postgres does for VARCHAR(n).
151fn validate_name(name: &str) -> Result<&str, ApiError> {
152    let name = name.trim();
153    if name.is_empty() {
154        return Err(ApiError::BadRequest("name must not be empty".to_string()));
155    }
156    if name.chars().count() > MAX_API_KEY_NAME_LEN {
157        return Err(ApiError::BadRequest(format!(
158            "name must be at most {MAX_API_KEY_NAME_LEN} characters"
159        )));
160    }
161    Ok(name)
162}
163
164/// Generate a random API key with the `irfl_` prefix.
165fn generate_api_key() -> String {
166    let mut rng = rand::rng();
167    let random_bytes: Vec<u8> = (0..32).map(|_| rng.random::<u8>()).collect();
168    let encoded = hex::encode(&random_bytes);
169    format!("{API_KEY_PREFIX}{encoded}")
170}
171
172#[cfg(test)]
173mod tests {
174    use axum::Router;
175    use axum::body::Body;
176    use axum::http::Request;
177    use axum::routing::post;
178    use http_body_util::BodyExt;
179    use serde_json::{Value as JsonValue, from_slice};
180    use tower::ServiceExt;
181
182    use super::*;
183    use crate::routes::test_helpers::{create_user_auth_header, test_state};
184
185    use std::sync::Arc;
186
187    use ironflow_auth::jwt::JwtConfig;
188    use ironflow_core::providers::claude::ClaudeCodeProvider;
189    use ironflow_engine::engine::Engine;
190    use ironflow_engine::notify::Event;
191    use ironflow_store::entities::NewUser;
192    use ironflow_store::memory::InMemoryStore;
193    use ironflow_store::store::Store;
194    use tokio::sync::broadcast;
195
196    async fn admin_state() -> (AppState, AuthenticatedUser) {
197        let store: Arc<dyn Store> = Arc::new(InMemoryStore::new());
198        let provider = Arc::new(ClaudeCodeProvider::new());
199        let engine = Arc::new(Engine::new(store.clone(), provider));
200        let jwt_config = Arc::new(JwtConfig {
201            secret: "test-secret-for-api-keys".to_string(),
202            access_token_ttl_secs: 900,
203            refresh_token_ttl_secs: 604800,
204            cookie_domain: None,
205            cookie_secure: false,
206        });
207        let (event_sender, _) = broadcast::channel::<Event>(1);
208        let state = AppState::new(
209            store.clone(),
210            engine,
211            jwt_config,
212            "test-worker-token".to_string(),
213            event_sender,
214        );
215
216        let user = store
217            .create_user(NewUser {
218                email: "admin@example.com".to_string(),
219                username: "admin".to_string(),
220                password_hash: password::hash("password123").expect("hash"),
221                is_admin: Some(true),
222            })
223            .await
224            .expect("create user");
225        let auth = AuthenticatedUser {
226            user_id: user.id,
227            username: user.username,
228            is_admin: true,
229        };
230        (state, auth)
231    }
232
233    fn request(name: String) -> CreateApiKeyRequest {
234        CreateApiKeyRequest {
235            name,
236            scopes: vec![ApiKeyScope::RunsRead],
237            expires_at: None,
238            rate_limit_override: None,
239        }
240    }
241
242    #[test]
243    fn validate_name_rejects_empty_and_whitespace() {
244        assert!(matches!(validate_name(""), Err(ApiError::BadRequest(_))));
245        assert!(matches!(validate_name("   "), Err(ApiError::BadRequest(_))));
246    }
247
248    #[test]
249    fn validate_name_accepts_exactly_255_chars() {
250        let name = "a".repeat(255);
251        assert_eq!(validate_name(&name).expect("valid"), name);
252    }
253
254    #[test]
255    fn validate_name_rejects_256_chars() {
256        let name = "a".repeat(256);
257        match validate_name(&name) {
258            Err(ApiError::BadRequest(msg)) => assert!(msg.contains("255")),
259            _ => panic!("expected BadRequest"),
260        }
261    }
262
263    #[test]
264    fn validate_name_trims_before_counting() {
265        let padded = format!("  {}  ", "a".repeat(255));
266        let name = validate_name(&padded).expect("valid");
267        assert_eq!(name.len(), 255);
268    }
269
270    #[test]
271    fn validate_name_counts_chars_not_bytes() {
272        let ok = "é".repeat(255);
273        assert!(validate_name(&ok).is_ok());
274        let too_long = "é".repeat(256);
275        assert!(matches!(
276            validate_name(&too_long),
277            Err(ApiError::BadRequest(_))
278        ));
279    }
280
281    #[tokio::test]
282    async fn create_api_key_name_longer_than_255_returns_400() {
283        let (state, auth) = admin_state().await;
284        let user_id = auth.user_id;
285        let store = state.store.clone();
286
287        let result = create_api_key(auth, State(state), Json(request("a".repeat(300)))).await;
288        assert!(matches!(result, Err(ApiError::BadRequest(_))));
289
290        let keys = store
291            .list_api_keys_by_user(user_id)
292            .await
293            .expect("list keys");
294        assert!(keys.is_empty());
295    }
296
297    #[tokio::test]
298    async fn create_api_key_name_of_255_chars_returns_201() {
299        let (state, auth) = admin_state().await;
300
301        let response = create_api_key(auth, State(state), Json(request("a".repeat(255))))
302            .await
303            .expect("created")
304            .into_response();
305        assert_eq!(response.status(), StatusCode::CREATED);
306    }
307
308    #[tokio::test]
309    async fn create_api_key_stores_trimmed_name() {
310        let (state, auth) = admin_state().await;
311        let user_id = auth.user_id;
312        let store = state.store.clone();
313
314        let response = create_api_key(auth, State(state), Json(request("  key  ".to_string())))
315            .await
316            .expect("created")
317            .into_response();
318        assert_eq!(response.status(), StatusCode::CREATED);
319
320        let keys = store
321            .list_api_keys_by_user(user_id)
322            .await
323            .expect("list keys");
324        assert_eq!(keys.len(), 1);
325        assert_eq!(keys[0].name, "key");
326    }
327
328    #[test]
329    fn generate_api_key_has_correct_prefix() {
330        let key = generate_api_key();
331        assert!(key.starts_with(API_KEY_PREFIX));
332    }
333
334    #[test]
335    fn generate_api_key_is_long_enough_for_prefix_extraction() {
336        let key = generate_api_key();
337        let expected_prefix_len = API_KEY_PREFIX.len() + API_KEY_SUFFIX_LEN;
338        assert!(
339            key.len() >= expected_prefix_len,
340            "key length {} is shorter than expected prefix length {}",
341            key.len(),
342            expected_prefix_len
343        );
344
345        let prefix = &key[..expected_prefix_len];
346        assert_eq!(prefix.len(), 13);
347        assert!(prefix.starts_with(API_KEY_PREFIX));
348    }
349
350    #[test]
351    fn generate_api_key_prefix_fits_varchar_16() {
352        let key = generate_api_key();
353        let prefix = &key[..API_KEY_PREFIX.len() + API_KEY_SUFFIX_LEN];
354        assert!(
355            prefix.len() <= 16,
356            "prefix length {} exceeds VARCHAR(16)",
357            prefix.len()
358        );
359    }
360
361    #[test]
362    fn generate_api_key_produces_unique_keys() {
363        let key1 = generate_api_key();
364        let key2 = generate_api_key();
365        assert_ne!(key1, key2);
366    }
367
368    async fn post_create(is_admin: bool, body: &str) -> (StatusCode, JsonValue) {
369        let state = test_state();
370        let username = if is_admin { "admin" } else { "member" };
371        let auth = create_user_auth_header(&state, username, is_admin).await;
372        let app = Router::new()
373            .route("/", post(create_api_key))
374            .with_state(state);
375        let req = Request::builder()
376            .method("POST")
377            .uri("/")
378            .header("authorization", auth)
379            .header("content-type", "application/json")
380            .body(Body::from(body.to_string()))
381            .unwrap();
382        let resp = app.oneshot(req).await.unwrap();
383        let status = resp.status();
384        let bytes = resp.into_body().collect().await.unwrap().to_bytes();
385        let json = if bytes.is_empty() {
386            JsonValue::Null
387        } else {
388            from_slice(&bytes).unwrap()
389        };
390        (status, json)
391    }
392
393    #[tokio::test]
394    async fn member_setting_rate_limit_override_is_forbidden() {
395        let body = r#"{"name":"k","scopes":["runs_read"],"rate_limit_override":0}"#;
396        let (status, _) = post_create(false, body).await;
397        assert_eq!(status, StatusCode::FORBIDDEN);
398    }
399
400    #[tokio::test]
401    async fn admin_can_set_rate_limit_override() {
402        let body = r#"{"name":"k","scopes":["runs_read"],"rate_limit_override":0}"#;
403        let (status, json) = post_create(true, body).await;
404        assert_eq!(status, StatusCode::CREATED);
405        assert_eq!(json["data"]["rate_limit_override"], 0);
406    }
407
408    #[tokio::test]
409    async fn member_without_rate_limit_override_can_create_key() {
410        let body = r#"{"name":"k","scopes":["runs_read"]}"#;
411        let (status, json) = post_create(false, body).await;
412        assert_eq!(status, StatusCode::CREATED);
413        assert!(json["data"]["rate_limit_override"].is_null());
414    }
415}