Skip to main content

ironflow_api/
provider_accounts.rs

1//! Provider Account business logic, shared by the `/provider-accounts` routes.
2//!
3//! Routes stay thin: they authorize, parse, and call into this module. The
4//! credential is written to the system secret `accounts/<id>/credential` and
5//! never leaves it: no response, log, audit payload or event carries it.
6
7use 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
33/// Default lifetime of a credential when the request gives no expiry.
34const DEFAULT_CREDENTIAL_DAYS: i64 = 365;
35
36/// Access level a route requires.
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub enum AccountScope {
39    /// Read accounts and their usage.
40    Read,
41    /// Create, update, delete and test accounts.
42    Manage,
43}
44
45/// Check that the caller may use the Provider Account routes.
46///
47/// Admin only. An API key must also carry `accounts_read`, `accounts_manage`
48/// or `admin`, depending on `scope`.
49///
50/// # Errors
51///
52/// Returns [`ApiError::Forbidden`] for a non-admin caller and
53/// [`ApiError::InsufficientScope`] for an admin API key missing the scope.
54pub 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        // Manage implies read.
64        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
74/// Find an account by UUID or by name.
75///
76/// # Errors
77///
78/// Returns [`ApiError::ProviderAccountNotFound`] when neither matches.
79pub 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
91/// Derive the display state of an account.
92///
93/// Only unscoped windows count: a model-scoped window blocks one model family,
94/// not the account.
95pub 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
124/// Build the response of one account, reading its windows.
125///
126/// # Errors
127///
128/// Returns [`ApiError::Store`] on storage failure.
129pub 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
148/// Validate the format, then check the credential against the provider.
149async 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
189/// Push an event to the SSE clients.
190fn publish(state: &AppState, event: Event) {
191    // A send error only means no SSE client is listening.
192    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    // Identifiers only: the credential never reaches the audit log.
203    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
234/// Create an account after checking its credential against the provider.
235///
236/// The secret is written before the row and deleted again when the insert
237/// fails, so neither is left behind.
238///
239/// # Errors
240///
241/// [`ApiError::BadRequest`] for invalid input or an unknown kind,
242/// [`ApiError::AccountCredentialRejected`] for a malformed or rejected token,
243/// [`ApiError::Conflict`] for a taken name, [`ApiError::BadGateway`] when the
244/// provider cannot be reached.
245pub 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
316/// Update an account; a new token is checked before it replaces the old one.
317///
318/// # Errors
319///
320/// Same as [`create`], plus [`ApiError::ProviderAccountNotFound`].
321pub 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
386/// Delete an account and its credential.
387///
388/// # Errors
389///
390/// Returns [`ApiError::Store`] when the row or the secret cannot be deleted.
391pub 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
410/// Check the stored credential against the provider and record the result.
411///
412/// # Errors
413///
414/// [`ApiError::AccountCredentialRejected`] when no credential is stored,
415/// [`ApiError::BadGateway`] when the provider cannot be reached.
416pub 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}