1use std::sync::Arc;
8
9use chrono::{DateTime, TimeDelta, Utc};
10use serde_json::json;
11use tracing::{error, warn};
12use uuid::Uuid;
13
14use ironflow_auth::extractor::{AuthMethod, Authenticated};
15use ironflow_core::account::{AccountError, AccountKind, AccountWindow, CredentialCheck};
16use ironflow_engine::accounts::{window_from_store, window_to_store};
17use ironflow_engine::notify::{
18 Event, ProviderAccountChange, ProviderAccountUpdatedEvent, ProviderAccountUsageUpdatedEvent,
19};
20use ironflow_store::entities::{
21 ApiKeyScope, EventKind, NewAuditLogEntry, NewProviderAccount, NewProviderAccountObservation,
22 ProviderAccount, ProviderAccountUpdate, ProviderAccountWindow, provider_account_secret_key,
23};
24use ironflow_store::store::Store;
25
26use crate::entities::{
27 AccountState, AccountTestResult, CreateProviderAccountRequest, ProviderAccountResponse,
28 ProviderAccountTestResponse, UpdateProviderAccountRequest,
29};
30use crate::error::ApiError;
31use crate::state::AppState;
32
33const DEFAULT_CREDENTIAL_DAYS: i64 = 365;
35
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub enum AccountScope {
39 Read,
41 Manage,
43}
44
45pub fn authorize(auth: &Authenticated, scope: AccountScope) -> Result<(), ApiError> {
55 if !auth.is_admin() {
56 return Err(ApiError::Forbidden);
57 }
58 if let AuthMethod::ApiKey { scopes, .. } = &auth.method {
59 let required = match scope {
60 AccountScope::Read => ApiKeyScope::AccountsRead,
61 AccountScope::Manage => ApiKeyScope::AccountsManage,
62 };
63 let permitted = ApiKeyScope::has_permission(scopes, &required)
65 || (scope == AccountScope::Read
66 && ApiKeyScope::has_permission(scopes, &ApiKeyScope::AccountsManage));
67 if !permitted {
68 return Err(ApiError::InsufficientScope);
69 }
70 }
71 Ok(())
72}
73
74pub async fn resolve(store: &dyn Store, id_or_name: &str) -> Result<ProviderAccount, ApiError> {
80 let by_id = match Uuid::parse_str(id_or_name) {
81 Ok(id) => store.get_provider_account(id).await?,
82 Err(_) => None,
83 };
84 let found = match by_id {
85 Some(account) => Some(account),
86 None => store.find_provider_account_by_name(id_or_name).await?,
87 };
88 found.ok_or_else(|| ApiError::ProviderAccountNotFound(id_or_name.to_string()))
89}
90
91pub fn account_state(
96 account: &ProviderAccount,
97 windows: &[ProviderAccountWindow],
98 now: DateTime<Utc>,
99) -> AccountState {
100 if account.auth_failed_at.is_some() {
101 return AccountState::TokenInvalid;
102 }
103 if windows.is_empty() {
104 return AccountState::NeverUsed;
105 }
106 let unscoped: Vec<AccountWindow> = windows
107 .iter()
108 .filter(|w| w.model_scope.is_none())
109 .map(window_from_store)
110 .collect();
111 if unscoped.iter().any(|w| w.is_exhausted(now)) {
112 return AccountState::Limited;
113 }
114 let max_utilization = unscoped
115 .iter()
116 .map(|w| w.effective_utilization(now))
117 .fold(0.0, f64::max);
118 if max_utilization >= account.alert_threshold {
119 return AccountState::NearLimit;
120 }
121 AccountState::Ok
122}
123
124pub async fn to_response(
130 store: &dyn Store,
131 account: ProviderAccount,
132) -> Result<ProviderAccountResponse, ApiError> {
133 let windows = store
134 .list_provider_account_windows(vec![account.id])
135 .await?;
136 let state = account_state(&account, &windows, Utc::now());
137 Ok(ProviderAccountResponse::new(account, windows, state))
138}
139
140fn kind_of(state: &AppState, kind: &str) -> Result<Arc<dyn AccountKind>, ApiError> {
141 state
142 .account_kinds
143 .get(kind)
144 .cloned()
145 .ok_or_else(|| ApiError::BadRequest(format!("unknown account kind '{kind}'")))
146}
147
148async fn check_token(kind: &dyn AccountKind, token: &str) -> Result<CredentialCheck, ApiError> {
150 kind.validate_credential(token)
151 .map_err(|e| ApiError::AccountCredentialRejected(e.to_string()))?;
152 kind.check_credential(token).await.map_err(|e| match e {
153 AccountError::InvalidCredential(message) => ApiError::AccountCredentialRejected(message),
154 AccountError::Unauthorized { status } => ApiError::AccountCredentialRejected(format!(
155 "token rejected by provider (HTTP {status})"
156 )),
157 AccountError::CheckFailed(message) => ApiError::BadGateway(message),
158 })
159}
160
161async fn record_windows(
162 state: &AppState,
163 account: &ProviderAccount,
164 windows: Vec<AccountWindow>,
165 auth_failed: bool,
166) -> Result<Vec<ProviderAccountWindow>, ApiError> {
167 let recorded = state
168 .store
169 .record_provider_account_observation(
170 account.id,
171 NewProviderAccountObservation {
172 windows: windows.into_iter().map(window_to_store).collect(),
173 auth_failed,
174 },
175 )
176 .await?;
177 publish(
178 state,
179 Event::ProviderAccountUsageUpdated(ProviderAccountUsageUpdatedEvent {
180 account_id: account.id,
181 name: account.name.clone(),
182 windows: recorded.clone(),
183 at: Utc::now(),
184 }),
185 );
186 Ok(recorded)
187}
188
189fn publish(state: &AppState, event: Event) {
191 let _ = state.event_sender.send(event);
193}
194
195async fn audit_and_publish(
196 state: &AppState,
197 user_id: Uuid,
198 account: &ProviderAccount,
199 change: ProviderAccountChange,
200 fields: Option<Vec<&'static str>>,
201) -> Result<(), ApiError> {
202 let mut payload = json!({
204 "change": change.as_str(),
205 "account_id": account.id,
206 "name": account.name,
207 "kind": account.kind,
208 });
209 if let Some(fields) = fields {
210 payload["fields"] = json!(fields);
211 }
212 state
213 .store
214 .append_audit_log(NewAuditLogEntry {
215 event_type: EventKind::ProviderAccountUpdated,
216 payload,
217 run_id: None,
218 step_id: None,
219 user_id: Some(user_id),
220 })
221 .await?;
222 publish(
223 state,
224 Event::ProviderAccountUpdated(ProviderAccountUpdatedEvent {
225 account_id: account.id,
226 name: account.name.clone(),
227 change,
228 at: Utc::now(),
229 }),
230 );
231 Ok(())
232}
233
234pub async fn create(
246 state: &AppState,
247 user_id: Uuid,
248 req: CreateProviderAccountRequest,
249) -> Result<ProviderAccount, ApiError> {
250 let kind = kind_of(state, &req.kind)?;
251 let token = req.token.trim().to_string();
252 kind.validate_credential(&token)
253 .map_err(|e| ApiError::AccountCredentialRejected(e.to_string()))?;
254 if state
255 .store
256 .find_provider_account_by_name(&req.name)
257 .await?
258 .is_some()
259 {
260 return Err(ApiError::Conflict(format!(
261 "provider account '{}' already exists",
262 req.name
263 )));
264 }
265 let check = check_token(kind.as_ref(), &token).await?;
266
267 let id = Uuid::now_v7();
268 let secret_key = provider_account_secret_key(id);
269 state.store.set_secret(&secret_key, &token).await?;
270
271 let created = state
272 .store
273 .create_provider_account(NewProviderAccount {
274 id,
275 display_name: req.display_name.unwrap_or_else(|| req.name.clone()),
276 name: req.name,
277 kind: kind.id().to_string(),
278 secret_key: secret_key.clone(),
279 enabled: req.enabled.unwrap_or(true),
280 priority: req.priority.unwrap_or(100),
281 tags: req.tags.unwrap_or_default(),
282 max_concurrency: req.max_concurrency,
283 alert_threshold: req.alert_threshold.unwrap_or(0.8),
284 expires_at: req
285 .expires_at
286 .unwrap_or_else(|| Utc::now() + TimeDelta::days(DEFAULT_CREDENTIAL_DAYS)),
287 plan: req.plan,
288 created_by: Some(user_id),
289 })
290 .await;
291 let account = match created {
292 Ok(account) => account,
293 Err(e) => {
294 if let Err(delete_err) = state.store.delete_secret(&secret_key).await {
295 error!(account_id = %id, error = %delete_err, "failed to delete orphan provider account credential");
296 }
297 return Err(e.into());
298 }
299 };
300
301 let windows = check.windows().to_vec();
302 if !windows.is_empty() {
303 record_windows(state, &account, windows, false).await?;
304 }
305 audit_and_publish(
306 state,
307 user_id,
308 &account,
309 ProviderAccountChange::Created,
310 None,
311 )
312 .await?;
313 Ok(account)
314}
315
316pub async fn update(
322 state: &AppState,
323 user_id: Uuid,
324 account: ProviderAccount,
325 req: UpdateProviderAccountRequest,
326) -> Result<ProviderAccount, ApiError> {
327 let fields = req.changed_fields();
328 let mut update = ProviderAccountUpdate {
329 display_name: req.display_name,
330 enabled: req.enabled,
331 priority: req.priority,
332 tags: req.tags,
333 max_concurrency: req.max_concurrency,
334 alert_threshold: req.alert_threshold,
335 expires_at: req.expires_at,
336 plan: req.plan,
337 auth_failed_at: None,
338 };
339
340 let mut check_windows = None;
341 if let Some(token) = req.token {
342 let kind = kind_of(state, &account.kind)?;
343 let token = token.trim().to_string();
344 let check = check_token(kind.as_ref(), &token).await?;
345 state.store.set_secret(&account.secret_key, &token).await?;
346 update.auth_failed_at = Some(None);
347 if update.expires_at.is_none() {
348 update.expires_at = Some(Utc::now() + TimeDelta::days(DEFAULT_CREDENTIAL_DAYS));
349 }
350 check_windows = Some(check.windows().to_vec());
351 }
352
353 let updated = state
354 .store
355 .update_provider_account(account.id, update)
356 .await?;
357
358 match check_windows {
359 Some(windows) => {
360 if !windows.is_empty() {
361 record_windows(state, &updated, windows, false).await?;
362 }
363 audit_and_publish(
364 state,
365 user_id,
366 &updated,
367 ProviderAccountChange::TokenReplaced,
368 Some(fields),
369 )
370 .await?;
371 }
372 None => {
373 audit_and_publish(
374 state,
375 user_id,
376 &updated,
377 ProviderAccountChange::Updated,
378 Some(fields),
379 )
380 .await?;
381 }
382 }
383 Ok(updated)
384}
385
386pub async fn delete(
392 state: &AppState,
393 user_id: Uuid,
394 account: ProviderAccount,
395) -> Result<(), ApiError> {
396 if !state.store.delete_provider_account(account.id).await? {
397 return Err(ApiError::ProviderAccountNotFound(account.id.to_string()));
398 }
399 state.store.delete_secret(&account.secret_key).await?;
400 audit_and_publish(
401 state,
402 user_id,
403 &account,
404 ProviderAccountChange::Deleted,
405 None,
406 )
407 .await
408}
409
410pub async fn test(
417 state: &AppState,
418 account: &ProviderAccount,
419) -> Result<ProviderAccountTestResponse, ApiError> {
420 let kind = kind_of(state, &account.kind)?;
421 let secret = state
422 .store
423 .get_secret(&account.secret_key)
424 .await?
425 .ok_or_else(|| ApiError::AccountCredentialRejected("no credential stored".to_string()))?;
426
427 let (result, windows, auth_failed) = match kind.check_credential(&secret.value).await {
428 Ok(CredentialCheck::Valid { windows }) => (AccountTestResult::Valid, windows, false),
429 Ok(CredentialCheck::Limited { windows }) => (AccountTestResult::Limited, windows, false),
430 Err(AccountError::Unauthorized { status }) => {
431 warn!(account = %account.name, status, "provider rejected the stored credential");
432 (AccountTestResult::Unauthorized, Vec::new(), true)
433 }
434 Err(AccountError::InvalidCredential(message)) => {
435 return Err(ApiError::AccountCredentialRejected(message));
436 }
437 Err(AccountError::CheckFailed(message)) => return Err(ApiError::BadGateway(message)),
438 };
439
440 let recorded = if windows.is_empty() && !auth_failed {
441 Vec::new()
442 } else {
443 record_windows(state, account, windows, auth_failed).await?
444 };
445 Ok(ProviderAccountTestResponse {
446 result,
447 windows: recorded.into_iter().map(Into::into).collect(),
448 })
449}
450
451#[cfg(test)]
452mod tests {
453 use ironflow_store::entities::AccountWindowStatus;
454
455 use super::*;
456
457 fn account(threshold: f64) -> ProviderAccount {
458 let now = Utc::now();
459 ProviderAccount {
460 id: Uuid::now_v7(),
461 name: "perso".to_string(),
462 display_name: "Perso".to_string(),
463 kind: "claude_subscription".to_string(),
464 secret_key: "accounts/x/credential".to_string(),
465 enabled: true,
466 priority: 100,
467 tags: Vec::new(),
468 max_concurrency: None,
469 alert_threshold: threshold,
470 expires_at: now + TimeDelta::days(1),
471 plan: None,
472 auth_failed_at: None,
473 created_by: None,
474 created_at: now,
475 updated_at: now,
476 }
477 }
478
479 fn window(
480 utilization: f64,
481 status: AccountWindowStatus,
482 scope: Option<&str>,
483 ) -> ProviderAccountWindow {
484 ProviderAccountWindow {
485 account_id: Uuid::now_v7(),
486 window: "five_hour".to_string(),
487 utilization,
488 resets_at: Some(Utc::now() + TimeDelta::hours(1)),
489 status,
490 model_scope: scope.map(str::to_string),
491 observed_at: Utc::now(),
492 }
493 }
494
495 #[test]
496 fn account_state_covers_every_variant() {
497 let now = Utc::now();
498 let ok = account(0.8);
499 assert_eq!(account_state(&ok, &[], now), AccountState::NeverUsed);
500 assert_eq!(
501 account_state(&ok, &[window(0.2, AccountWindowStatus::Allowed, None)], now),
502 AccountState::Ok
503 );
504 assert_eq!(
505 account_state(
506 &ok,
507 &[window(0.85, AccountWindowStatus::AllowedWarning, None)],
508 now
509 ),
510 AccountState::NearLimit
511 );
512 assert_eq!(
513 account_state(
514 &ok,
515 &[window(1.0, AccountWindowStatus::Rejected, None)],
516 now
517 ),
518 AccountState::Limited
519 );
520 let mut failed = account(0.8);
521 failed.auth_failed_at = Some(now);
522 assert_eq!(account_state(&failed, &[], now), AccountState::TokenInvalid);
523 }
524
525 #[test]
526 fn account_state_ignores_model_scoped_windows() {
527 let now = Utc::now();
528 let windows = [window(1.0, AccountWindowStatus::Rejected, Some("opus"))];
529 assert_eq!(
530 account_state(&account(0.8), &windows, now),
531 AccountState::Ok
532 );
533 }
534
535 #[test]
536 fn authorize_rejects_members_and_unscoped_keys() {
537 let member = Authenticated {
538 user_id: Uuid::now_v7(),
539 method: AuthMethod::Jwt {
540 username: "bob".to_string(),
541 is_admin: false,
542 },
543 };
544 assert!(matches!(
545 authorize(&member, AccountScope::Read),
546 Err(ApiError::Forbidden)
547 ));
548
549 let key = |scopes: Vec<ApiKeyScope>| Authenticated {
550 user_id: Uuid::now_v7(),
551 method: AuthMethod::ApiKey {
552 key_id: Uuid::now_v7(),
553 key_name: "ci".to_string(),
554 scopes,
555 owner_is_admin: true,
556 },
557 };
558 assert!(matches!(
559 authorize(&key(vec![ApiKeyScope::RunsRead]), AccountScope::Read),
560 Err(ApiError::InsufficientScope)
561 ));
562 assert!(authorize(&key(vec![ApiKeyScope::AccountsRead]), AccountScope::Read).is_ok());
563 assert!(matches!(
564 authorize(&key(vec![ApiKeyScope::AccountsRead]), AccountScope::Manage),
565 Err(ApiError::InsufficientScope)
566 ));
567 assert!(authorize(&key(vec![ApiKeyScope::AccountsManage]), AccountScope::Read).is_ok());
568 assert!(authorize(&key(vec![ApiKeyScope::Admin]), AccountScope::Manage).is_ok());
569 }
570}