Skip to main content

cratestack_core/context/
principal.rs

1//! Structured principal context — actor / session / tenant facets
2//! plus an arbitrary claims bag. Resolves dotted path lookups
3//! (`actor.id`, `tenant.slug`) so audit + policy code never reaches
4//! into the raw claims map directly.
5
6use std::collections::BTreeMap;
7
8use serde::{Deserialize, Serialize};
9
10use crate::error::CoolError;
11use crate::value::Value;
12
13use super::identity::CoolAuthIdentity;
14
15#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
16pub struct PrincipalFacet {
17    pub fields: BTreeMap<String, Value>,
18}
19
20#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
21pub struct PrincipalContext {
22    pub actor: Option<PrincipalFacet>,
23    pub session: Option<PrincipalFacet>,
24    pub tenant: Option<PrincipalFacet>,
25    pub claims: BTreeMap<String, Value>,
26}
27
28impl PrincipalContext {
29    pub fn from_principal<P: Serialize>(principal: P) -> Result<Self, CoolError> {
30        let auth = CoolAuthIdentity::from_principal(principal)?;
31        Ok(Self::from_auth_identity(&auth))
32    }
33
34    pub fn from_claims(claims: BTreeMap<String, Value>) -> Self {
35        Self {
36            actor: None,
37            session: None,
38            tenant: None,
39            claims,
40        }
41    }
42
43    pub fn from_auth_identity(auth: &CoolAuthIdentity) -> Self {
44        let mut claims = auth.fields.clone();
45        let actor = take_principal_facet(&mut claims, "actor");
46        let session = take_principal_facet(&mut claims, "session");
47        let tenant = take_principal_facet(&mut claims, "tenant");
48        Self {
49            actor,
50            session,
51            tenant,
52            claims,
53        }
54    }
55
56    pub fn field(&self, name: &str) -> Option<&Value> {
57        if let Some(value) = self
58            .claims
59            .get(name)
60            .or_else(|| lookup_value_path_in_map(&self.claims, name))
61        {
62            return Some(value);
63        }
64
65        let (root, rest) = name.split_once('.')?;
66        match root {
67            "actor" => lookup_principal_facet_path(self.actor.as_ref(), rest),
68            "session" => lookup_principal_facet_path(self.session.as_ref(), rest),
69            "tenant" => lookup_principal_facet_path(self.tenant.as_ref(), rest),
70            _ => None,
71        }
72    }
73
74    pub fn as_auth_identity(&self) -> CoolAuthIdentity {
75        CoolAuthIdentity {
76            fields: self.legacy_fields(),
77        }
78    }
79
80    pub fn legacy_fields(&self) -> BTreeMap<String, Value> {
81        let mut fields = self.claims.clone();
82        if let Some(actor) = &self.actor {
83            fields.insert("actor".to_owned(), Value::Map(actor.fields.clone()));
84        }
85        if let Some(session) = &self.session {
86            fields.insert("session".to_owned(), Value::Map(session.fields.clone()));
87        }
88        if let Some(tenant) = &self.tenant {
89            fields.insert("tenant".to_owned(), Value::Map(tenant.fields.clone()));
90        }
91        fields
92    }
93}
94
95pub(super) fn lookup_value_path_in_map<'a>(
96    map: &'a BTreeMap<String, Value>,
97    path: &str,
98) -> Option<&'a Value> {
99    let mut segments = path.split('.');
100    let first = segments.next()?;
101    let mut current = map.get(first)?;
102    for segment in segments {
103        current = match current {
104            Value::Map(entries) => entries.get(segment)?,
105            _ => return None,
106        };
107    }
108    Some(current)
109}
110
111fn lookup_principal_facet_path<'a>(
112    facet: Option<&'a PrincipalFacet>,
113    path: &str,
114) -> Option<&'a Value> {
115    let facet = facet?;
116    facet
117        .fields
118        .get(path)
119        .or_else(|| lookup_value_path_in_map(&facet.fields, path))
120}
121
122fn take_principal_facet(claims: &mut BTreeMap<String, Value>, key: &str) -> Option<PrincipalFacet> {
123    match claims.remove(key) {
124        Some(Value::Map(fields)) => Some(PrincipalFacet { fields }),
125        Some(value) => {
126            claims.insert(key.to_owned(), value);
127            None
128        }
129        None => None,
130    }
131}