Skip to main content

systemprompt_security/credential/
scope.rs

1//! What a credential knows about *where* it may be spent, and how it is sent.
2//!
3//! A credential is not only a secret: it also carries the coordinates the
4//! endpoint needs. A Google service-account key names the one Cloud project
5//! any token minted from it can address; a future workload identity will name
6//! a region or an account. [`CredentialScope`] is that set of coordinates,
7//! and [`fill_endpoint`] is the only place a catalog endpoint template is
8//! resolved against them — so an endpoint asking for a coordinate the
9//! credential does not carry is refused rather than guessed at.
10//!
11//! [`fill_endpoint`] is the generalisation of what used to be a `{project}`
12//! substitution: the catalog never carries a tenant identifier, so a secret
13//! swapped for another customer's re-targets the endpoint with it. An
14//! endpoint that asks for a coordinate this credential cannot supply cannot be
15//! served — guessing one would send the request somewhere the operator never
16//! chose. `PLACEHOLDERS` is every placeholder a template may ask for.
17//!
18//! Copyright (c) systemprompt.io — Business Source License 1.1.
19//! See <https://systemprompt.io> for licensing details.
20
21use std::fmt;
22
23use super::error::CredentialError;
24
25pub use systemprompt_models::services::providers::{PROJECT_PLACEHOLDER, REGION_PLACEHOLDER};
26
27/// How an upstream expects the credential to be presented: as
28/// `Authorization: Bearer <token>` (a minted, expiring OAuth token) or in the
29/// provider's own API-key header, sent verbatim by the adapter.
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub enum AuthScheme {
32    Bearer,
33    ApiKey,
34}
35
36/// The credential, resolved into the exact value an adapter will send.
37///
38/// `Debug` is hand-written: this type exists to be logged near, never logged.
39#[derive(Clone)]
40pub struct AuthHeader {
41    pub scheme: AuthScheme,
42    pub value: String,
43}
44
45impl AuthHeader {
46    #[must_use]
47    pub const fn is_bearer(&self) -> bool {
48        matches!(self.scheme, AuthScheme::Bearer)
49    }
50}
51
52impl fmt::Debug for AuthHeader {
53    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
54        f.debug_struct("AuthHeader")
55            .field("scheme", &self.scheme)
56            .field("value", &"<redacted>")
57            .finish()
58    }
59}
60
61/// The coordinates a credential supplies to the endpoint it authenticates.
62///
63/// The cloud project, account or tenant it is confined to; the region, when
64/// it is confined to one; and the principal it acts as, for audit — never a
65/// secret.
66///
67/// Every field is optional because most credentials supply none of them: an
68/// API key is a bare string and names nothing (`empty`).
69#[derive(Debug, Clone, Default, PartialEq, Eq)]
70pub struct CredentialScope {
71    pub project: Option<String>,
72    pub region: Option<String>,
73    pub principal: Option<String>,
74}
75
76impl CredentialScope {
77    #[must_use]
78    pub const fn empty() -> Self {
79        Self {
80            project: None,
81            region: None,
82            principal: None,
83        }
84    }
85
86    fn value_for(&self, placeholder: &str) -> Option<&str> {
87        let value = match placeholder {
88            PROJECT_PLACEHOLDER => self.project.as_deref(),
89            REGION_PLACEHOLDER => self.region.as_deref(),
90            _ => None,
91        };
92        // Why: an empty coordinate is no coordinate. Substituting one would
93        // produce `/projects//locations/...`, which reaches the upstream and
94        // fails there with an error about a path rather than a credential.
95        value.filter(|v| !v.is_empty())
96    }
97}
98
99const PLACEHOLDERS: &[(&str, &str)] = &[
100    (PROJECT_PLACEHOLDER, "project id"),
101    (REGION_PLACEHOLDER, "region"),
102];
103
104pub fn fill_endpoint(template: &str, scope: &CredentialScope) -> Result<String, CredentialError> {
105    let mut endpoint = template.to_owned();
106    for &(placeholder, field) in PLACEHOLDERS {
107        if !endpoint.contains(placeholder) {
108            continue;
109        }
110        let Some(value) = scope.value_for(placeholder) else {
111            return Err(CredentialError::MissingScope {
112                endpoint: template.to_owned(),
113                field,
114                placeholder,
115            });
116        };
117        endpoint = endpoint.replace(placeholder, value);
118    }
119    Ok(endpoint)
120}