Skip to main content

systemprompt_security/credential/
mod.rs

1//! One model for every upstream credential this instance holds.
2//!
3//! A secret is parsed once into a [`ProviderCredential`]; everything
4//! downstream asks the credential for its scope and its auth header and never
5//! inspects the secret again. That single rule is what makes the next
6//! credential type — a workload identity, an OIDC client, a scoped key with a
7//! tenant id — a new variant here rather than another branch in the gateway,
8//! the loader and the bridge.
9//!
10//! The kinds are told apart by the secret's own content rather than by a
11//! catalog flag. A Google service-account key is a JSON document that names
12//! itself in a `type` field (`"service_account"`), which is an explicit,
13//! Google-defined self-description rather than a guess about shape; anything
14//! that is not such a document is an API key. So the provider catalog carries
15//! no credential-type column, and an operator who pastes a service account
16//! gets the right behaviour without having to know a flag exists.
17//! [`ProviderCredential::parse`] is the only place that decision is made.
18//!
19//! A minted token is cached under the secret *name*, not its value, so two
20//! providers sharing a secret share one minted token.
21//!
22//! Copyright (c) systemprompt.io — Business Source License 1.1.
23//! See <https://systemprompt.io> for licensing details.
24
25pub mod cache;
26mod error;
27pub(crate) mod http;
28mod scope;
29
30use std::fmt;
31
32pub use error::CredentialError;
33pub use scope::{
34    AuthHeader, AuthScheme, CredentialScope, PROJECT_PLACEHOLDER, REGION_PLACEHOLDER, fill_endpoint,
35};
36
37use crate::google::{SERVICE_ACCOUNT_TYPE, ServiceAccountKey, access_token};
38
39/// An API key, held in a type that will not print itself. `expose` hands out
40/// the key itself; every call site is one that is about to send it.
41#[derive(Clone, PartialEq, Eq)]
42pub struct ApiKeySecret(String);
43
44impl ApiKeySecret {
45    #[must_use]
46    pub fn expose(&self) -> &str {
47        &self.0
48    }
49}
50
51impl fmt::Debug for ApiKeySecret {
52    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
53        f.write_str("ApiKeySecret(<redacted>)")
54    }
55}
56
57/// What kind of credential a secret turned out to hold.
58///
59/// Stable strings: they key the token cache and name the kind in operator
60/// output, so they are not derived from the variant name.
61#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62pub enum CredentialKind {
63    ApiKey,
64    GoogleServiceAccount,
65}
66
67impl CredentialKind {
68    #[must_use]
69    pub const fn as_str(self) -> &'static str {
70        match self {
71            Self::ApiKey => "api_key",
72            Self::GoogleServiceAccount => "google_service_account",
73        }
74    }
75}
76
77/// A credential an upstream provider will accept, parsed from its secret.
78///
79/// An API key is the credential and is sent verbatim; a Google service-account
80/// key is exchanged for a short-lived OAuth bearer token. `scope` is the
81/// coordinates the credential supplies to the endpoint it authenticates, and
82/// `fill_endpoint` resolves a catalog endpoint template against them.
83#[derive(Debug, Clone)]
84pub enum ProviderCredential {
85    ApiKey(ApiKeySecret),
86    GoogleServiceAccount(Box<ServiceAccountKey>),
87}
88
89impl ProviderCredential {
90    #[must_use]
91    pub fn api_key(key: impl Into<String>) -> Self {
92        Self::ApiKey(ApiKeySecret(key.into()))
93    }
94
95    pub fn parse(secret: &str) -> Result<Self, CredentialError> {
96        let Ok(value) = serde_json::from_str::<serde_json::Value>(secret) else {
97            return Ok(Self::ApiKey(ApiKeySecret(secret.to_owned())));
98        };
99        if value.get("type").and_then(serde_json::Value::as_str) != Some(SERVICE_ACCOUNT_TYPE) {
100            return Ok(Self::ApiKey(ApiKeySecret(secret.to_owned())));
101        }
102        serde_json::from_value::<ServiceAccountKey>(value)
103            .map(|key| Self::GoogleServiceAccount(Box::new(key)))
104            .map_err(|e| CredentialError::Malformed(e.to_string()))
105    }
106
107    #[must_use]
108    pub const fn kind(&self) -> CredentialKind {
109        match self {
110            Self::ApiKey(_) => CredentialKind::ApiKey,
111            Self::GoogleServiceAccount(_) => CredentialKind::GoogleServiceAccount,
112        }
113    }
114
115    #[must_use]
116    pub fn scope(&self) -> CredentialScope {
117        match self {
118            Self::ApiKey(_) => CredentialScope::empty(),
119            Self::GoogleServiceAccount(key) => CredentialScope {
120                project: Some(key.project_id.clone()),
121                region: None,
122                principal: Some(key.client_email.clone()),
123            },
124        }
125    }
126
127    pub async fn bearer(&self, cache_key: &str) -> Result<AuthHeader, CredentialError> {
128        match self {
129            Self::ApiKey(key) => Ok(AuthHeader {
130                scheme: AuthScheme::ApiKey,
131                value: key.expose().to_owned(),
132            }),
133            Self::GoogleServiceAccount(key) => {
134                let key_id = format!("{}:{cache_key}", self.kind().as_str());
135                Ok(AuthHeader {
136                    scheme: AuthScheme::Bearer,
137                    value: access_token(&key_id, key).await?,
138                })
139            },
140        }
141    }
142
143    pub fn fill_endpoint(&self, template: &str) -> Result<String, CredentialError> {
144        fill_endpoint(template, &self.scope())
145    }
146}