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}