Skip to main content

corium_authz/
subject.rs

1//! Mapping a request [`Principal`] onto the subjects a check searches from.
2//!
3//! A principal carries a subject id, the provider that vouched for it, roles,
4//! and free-form claims. Policy tuples are written against *objects*, so the
5//! check needs those facts as object references. Some of them are registered
6//! in the authz database (a principal entity with roles), and some are
7//! ephemeral — an OIDC `groups` claim becomes `group:eng` for the duration of
8//! this one request, with no tuple to maintain.
9
10use std::collections::BTreeSet;
11
12use corium_protocol::authz::Principal;
13
14use crate::model::ObjectRef;
15use crate::policy::Policy;
16
17/// How a [`Principal`]'s claims become subjects.
18#[derive(Clone, Debug)]
19pub struct SubjectMapping {
20    /// Claim key → object type. A claim's value may list several ids
21    /// separated by commas or spaces; each becomes its own subject.
22    pub claim_subjects: Vec<(String, String)>,
23    /// Require a principal to be registered in the authz database before its
24    /// bare `user:<id>` subject is used.
25    ///
26    /// Off by default, so a policy can name `user:alice` without registering
27    /// her first. Turn it on when several providers can mint identities and an
28    /// unregistered `alice` from one of them must not inherit tuples written
29    /// for another's. Provider-qualified subjects (`user:<provider>/<id>`) are
30    /// always derived and are never ambiguous.
31    pub require_registered_principal: bool,
32}
33
34impl Default for SubjectMapping {
35    fn default() -> Self {
36        Self {
37            claim_subjects: vec![
38                ("groups".to_owned(), "group".to_owned()),
39                ("group".to_owned(), "group".to_owned()),
40                ("tenant".to_owned(), "tenant".to_owned()),
41            ],
42            require_registered_principal: false,
43        }
44    }
45}
46
47/// Derives the subject set for `principal` under `policy`.
48///
49/// The set always contains the provider-qualified `user:<provider>/<id>`; the
50/// bare `user:<id>` is included unless policy data registers that id against a
51/// *different* provider (which would otherwise let one issuer mint an identity
52/// another issuer's tuples were written for).
53#[must_use]
54pub fn subjects_of(
55    principal: &Principal,
56    policy: &Policy,
57    mapping: &SubjectMapping,
58) -> BTreeSet<ObjectRef> {
59    let mut subjects = BTreeSet::new();
60    subjects.insert(ObjectRef::new(
61        "user",
62        format!("{}/{}", principal.provider, principal.subject),
63    ));
64
65    let registered = policy.principal(&principal.subject);
66    let provider_matches = registered
67        .and_then(|definition| definition.provider.as_deref())
68        .is_none_or(|provider| provider == principal.provider);
69    let admissible =
70        provider_matches && (registered.is_some() || !mapping.require_registered_principal);
71    if admissible {
72        subjects.insert(ObjectRef::new("user", principal.subject.clone()));
73    }
74
75    if !principal.is_anonymous() {
76        // Lets a tuple name `authenticated:*` — "any caller who authenticated",
77        // whoever they are.
78        subjects.insert(ObjectRef::new("authenticated", principal.subject.clone()));
79    }
80
81    let mut roles: BTreeSet<&str> = principal.roles.iter().map(String::as_str).collect();
82    if admissible && let Some(definition) = registered {
83        roles.extend(definition.roles.iter().map(String::as_str));
84    }
85    for role in roles {
86        subjects.insert(ObjectRef::new("role", role));
87    }
88
89    for (claim, kind) in &mapping.claim_subjects {
90        let Some(value) = principal.claim(claim) else {
91            continue;
92        };
93        for item in value.split([',', ' ']) {
94            let item = item.trim();
95            if !item.is_empty() {
96                subjects.insert(ObjectRef::new(kind.clone(), item));
97            }
98        }
99    }
100    subjects
101}
102
103/// A stable fingerprint of everything [`subjects_of`] reads, used as the key of
104/// the check-result cache. Two principals with the same fingerprint are
105/// interchangeable for authorization.
106#[must_use]
107pub fn fingerprint(principal: &Principal, mapping: &SubjectMapping) -> String {
108    let mut text = format!("{}|{}", principal.provider, principal.subject);
109    for role in &principal.roles {
110        text.push('|');
111        text.push_str(role);
112    }
113    for (claim, _) in &mapping.claim_subjects {
114        if let Some(value) = principal.claim(claim) {
115            text.push('|');
116            text.push_str(claim);
117            text.push('=');
118            text.push_str(value);
119        }
120    }
121    text
122}
123
124#[cfg(test)]
125mod tests {
126    use super::*;
127
128    fn refs(subjects: &BTreeSet<ObjectRef>) -> Vec<String> {
129        subjects.iter().map(ToString::to_string).collect()
130    }
131
132    #[test]
133    fn derives_user_role_and_claim_subjects() {
134        let principal = Principal::new("oidc", "alice")
135            .with_role("admin")
136            .with_claim("groups", "eng, sre")
137            .with_claim("tenant", "acme");
138        let subjects = subjects_of(&principal, &Policy::empty(), &SubjectMapping::default());
139        let rendered = refs(&subjects);
140        for expected in [
141            "user:alice",
142            "user:oidc/alice",
143            "authenticated:alice",
144            "role:admin",
145            "group:eng",
146            "group:sre",
147            "tenant:acme",
148        ] {
149            assert!(
150                rendered.contains(&expected.to_owned()),
151                "missing {expected} in {rendered:?}"
152            );
153        }
154    }
155
156    #[test]
157    fn anonymous_gets_no_authenticated_subject() {
158        let subjects = subjects_of(
159            &Principal::anonymous(),
160            &Policy::empty(),
161            &SubjectMapping::default(),
162        );
163        let rendered = refs(&subjects);
164        assert!(rendered.contains(&"user:anonymous".to_owned()));
165        assert!(
166            !rendered
167                .iter()
168                .any(|subject| subject.starts_with("authenticated:"))
169        );
170    }
171
172    #[test]
173    fn fingerprint_separates_providers_and_roles() {
174        let mapping = SubjectMapping::default();
175        let alice = Principal::new("oidc", "alice");
176        let other = Principal::new("static-token", "alice");
177        assert_ne!(fingerprint(&alice, &mapping), fingerprint(&other, &mapping));
178        assert_ne!(
179            fingerprint(&alice, &mapping),
180            fingerprint(&alice.clone().with_role("admin"), &mapping)
181        );
182    }
183}