1use std::collections::BTreeSet;
11
12use corium_protocol::authz::Principal;
13
14use crate::model::ObjectRef;
15use crate::policy::Policy;
16
17#[derive(Clone, Debug)]
19pub struct SubjectMapping {
20 pub claim_subjects: Vec<(String, String)>,
23 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#[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 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#[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}