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    pub fn parse(secret: &str) -> Result<Self, CredentialError> {
91        let Ok(value) = serde_json::from_str::<serde_json::Value>(secret) else {
92            return Ok(Self::ApiKey(ApiKeySecret(secret.to_owned())));
93        };
94        if value.get("type").and_then(serde_json::Value::as_str) != Some(SERVICE_ACCOUNT_TYPE) {
95            return Ok(Self::ApiKey(ApiKeySecret(secret.to_owned())));
96        }
97        serde_json::from_value::<ServiceAccountKey>(value)
98            .map(|key| Self::GoogleServiceAccount(Box::new(key)))
99            .map_err(|e| CredentialError::Malformed(e.to_string()))
100    }
101
102    #[must_use]
103    pub const fn kind(&self) -> CredentialKind {
104        match self {
105            Self::ApiKey(_) => CredentialKind::ApiKey,
106            Self::GoogleServiceAccount(_) => CredentialKind::GoogleServiceAccount,
107        }
108    }
109
110    #[must_use]
111    pub fn scope(&self) -> CredentialScope {
112        match self {
113            Self::ApiKey(_) => CredentialScope::empty(),
114            Self::GoogleServiceAccount(key) => CredentialScope {
115                project: Some(key.project_id.clone()),
116                region: None,
117                principal: Some(key.client_email.clone()),
118            },
119        }
120    }
121
122    pub async fn bearer(&self, cache_key: &str) -> Result<AuthHeader, CredentialError> {
123        match self {
124            Self::ApiKey(key) => Ok(AuthHeader {
125                scheme: AuthScheme::ApiKey,
126                value: key.expose().to_owned(),
127            }),
128            Self::GoogleServiceAccount(key) => {
129                let key_id = format!("{}:{cache_key}", self.kind().as_str());
130                Ok(AuthHeader {
131                    scheme: AuthScheme::Bearer,
132                    value: access_token(&key_id, key).await?,
133                })
134            },
135        }
136    }
137
138    pub fn fill_endpoint(&self, template: &str) -> Result<String, CredentialError> {
139        fill_endpoint(template, &self.scope())
140    }
141}