1use http::HeaderMap;
2use serde::{Deserialize, Serialize};
3use std::collections::{BTreeMap, BTreeSet};
4use thiserror::Error;
5
6use crate::request_id_from_headers;
7
8pub 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}