Skip to main content

minco_http/
principal.rs

1use http::HeaderMap;
2use serde::{Deserialize, Serialize};
3use std::collections::{BTreeMap, BTreeSet};
4use thiserror::Error;
5
6use crate::request_id_from_headers;
7
8/// Reserved provider-neutral claim used to carry normalized exact scope tokens.
9pub const PRINCIPAL_SCOPES_CLAIM: &str = "urn:minco:principal:scopes";
10
11#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
12pub struct Principal {
13    pub subject: String,
14    #[serde(default)]
15    pub permissions: BTreeSet<String>,
16    #[serde(default)]
17    pub claims: BTreeMap<String, String>,
18}
19
20impl std::fmt::Debug for Principal {
21    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
22        formatter
23            .debug_struct("Principal")
24            .field("subject", &self.subject)
25            .field("permissions", &self.permissions)
26            .field("claim_keys", &self.claims.keys().collect::<Vec<_>>())
27            .finish()
28    }
29}
30
31impl Principal {
32    pub fn has_permission(&self, permission: &str) -> bool {
33        self.permissions.contains(permission)
34    }
35
36    #[must_use]
37    pub fn with_scopes<I, S>(mut self, scopes: I) -> Self
38    where
39        I: IntoIterator<Item = S>,
40        S: AsRef<str>,
41    {
42        let scopes = scopes
43            .into_iter()
44            .map(|scope| scope.as_ref().to_owned())
45            .filter(|scope| {
46                !scope.is_empty() && !scope.bytes().any(|byte| byte.is_ascii_whitespace())
47            })
48            .collect::<BTreeSet<_>>()
49            .into_iter()
50            .collect::<Vec<_>>()
51            .join(" ");
52        if scopes.is_empty() {
53            self.claims.remove(PRINCIPAL_SCOPES_CLAIM);
54        } else {
55            self.claims
56                .insert(PRINCIPAL_SCOPES_CLAIM.to_owned(), scopes);
57        }
58        self
59    }
60
61    #[must_use]
62    pub fn has_scope(&self, scope: &str) -> bool {
63        self.claims
64            .get(PRINCIPAL_SCOPES_CLAIM)
65            .is_some_and(|scopes| scopes.split_ascii_whitespace().any(|value| value == scope))
66    }
67}
68
69#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
70pub struct RequestMetadata {
71    pub request_id: String,
72    pub principal: Option<Principal>,
73}
74
75pub fn principal_from_headers(
76    headers: &HeaderMap,
77    allow_development_headers: bool,
78) -> Result<RequestMetadata, PrincipalError> {
79    let request_id = request_id_from_headers(headers);
80    if !allow_development_headers {
81        return Ok(RequestMetadata {
82            request_id,
83            principal: None,
84        });
85    }
86    let Some(subject) = headers
87        .get("x-minco-subject")
88        .and_then(|value| value.to_str().ok())
89    else {
90        return Ok(RequestMetadata {
91            request_id,
92            principal: None,
93        });
94    };
95    if subject.trim().is_empty() {
96        return Err(PrincipalError::InvalidSubject);
97    }
98    let permissions = headers
99        .get("x-minco-permissions")
100        .and_then(|value| value.to_str().ok())
101        .unwrap_or_default()
102        .split(',')
103        .map(str::trim)
104        .filter(|value| !value.is_empty())
105        .map(str::to_owned)
106        .collect();
107    Ok(RequestMetadata {
108        request_id,
109        principal: Some(Principal {
110            subject: subject.to_owned(),
111            permissions,
112            claims: BTreeMap::new(),
113        }),
114    })
115}
116
117#[derive(Debug, Error, Clone, PartialEq, Eq)]
118pub enum PrincipalError {
119    #[error("principal subject is invalid")]
120    InvalidSubject,
121}
122
123#[cfg(test)]
124mod tests {
125    use super::*;
126    #[test]
127    fn development_headers_are_explicitly_opted_in() {
128        let mut headers = HeaderMap::new();
129        headers.insert("x-minco-subject", "user-1".parse().unwrap());
130        headers.insert(
131            "x-minco-permissions",
132            "orders.read,orders.create".parse().unwrap(),
133        );
134        assert!(
135            principal_from_headers(&headers, false)
136                .unwrap()
137                .principal
138                .is_none()
139        );
140        let principal = principal_from_headers(&headers, true)
141            .unwrap()
142            .principal
143            .unwrap();
144        assert!(principal.has_permission("orders.create"));
145    }
146}