Skip to main content

basil_core/core/
actor.rs

1// SPDX-FileCopyrightText: 2026 OpenBasil Contributors
2//
3// SPDX-License-Identifier: Apache-2.0
4
5//! Authenticated authorization actor resolution.
6
7use std::collections::BTreeSet;
8
9use crate::catalog::policy::{Config, ResolvedPolicy, SubjectName};
10use crate::peer::PeerInfo;
11
12/// A resolved actor that the `PDP` can authorize.
13#[derive(Debug, Clone, PartialEq, Eq)]
14pub struct AuthenticatedActor {
15    /// The subject selected for authorization.
16    pub subject: SubjectName,
17    /// Evidence summaries that established the subject.
18    pub authenticated_by: Vec<ProofSummary>,
19    /// The local presenter that brought the request to the broker.
20    pub presenter: PresenterInfo,
21    /// Transport facts for the request.
22    pub transport: TransportInfo,
23}
24
25impl AuthenticatedActor {
26    /// The presenter's `SO_PEERCRED` uid, when the actor came over a Unix socket.
27    #[must_use]
28    pub const fn unix_uid(&self) -> Option<u32> {
29        self.presenter.uid
30    }
31}
32
33/// Evidence that established an [`AuthenticatedActor`].
34#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct ProofSummary {
36    /// The kind of proof.
37    pub kind: ProofKind,
38    /// The subject established by this proof.
39    pub subject: SubjectName,
40}
41
42/// Supported first-cut proof kinds.
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44pub enum ProofKind {
45    /// Local Unix peer credentials from `SO_PEERCRED`.
46    UnixPeerCredentials,
47    /// Explicit configured anonymous subject.
48    Unauthenticated,
49    /// Signed sealed-invocation envelope with a configured public key.
50    SignatureKey,
51}
52
53/// Information about the process or bridge that presented the request.
54#[derive(Debug, Clone, PartialEq, Eq)]
55pub struct PresenterInfo {
56    /// `SO_PEERCRED` process id.
57    pub pid: Option<u32>,
58    /// `SO_PEERCRED` uid.
59    pub uid: Option<u32>,
60    /// `SO_PEERCRED` primary gid.
61    pub gid: Option<u32>,
62    /// Best-effort executable path from `/proc`.
63    pub executable_path: Option<String>,
64    /// Human-readable presenter label.
65    pub display_label: Option<String>,
66}
67
68impl From<&PeerInfo> for PresenterInfo {
69    fn from(peer: &PeerInfo) -> Self {
70        Self {
71            pid: peer.pid,
72            uid: peer.uid,
73            gid: peer.gid,
74            executable_path: peer.executable_path.clone(),
75            display_label: peer.display_label.clone(),
76        }
77    }
78}
79
80/// Transport facts for the request carrying the actor proof.
81#[derive(Debug, Clone, PartialEq, Eq)]
82pub struct TransportInfo {
83    /// The transport class.
84    pub kind: TransportKind,
85}
86
87/// Supported transport classes.
88#[derive(Debug, Clone, Copy, PartialEq, Eq)]
89pub enum TransportKind {
90    /// Local Unix-domain socket.
91    UnixSocket,
92}
93
94impl Default for TransportInfo {
95    fn default() -> Self {
96        Self {
97            kind: TransportKind::UnixSocket,
98        }
99    }
100}
101
102/// Why actor resolution failed.
103#[derive(Debug, Clone, PartialEq, Eq)]
104pub enum SubjectResolutionError {
105    /// No Unix credentials were present and no explicit unauthenticated subject applies.
106    MissingPeerCredentials,
107    /// Unix credentials were present but matched no subject.
108    NoSubject {
109        /// The presenter's uid.
110        uid: u32,
111    },
112    /// Unix credentials matched more than one subject and no request-level subject disambiguated it.
113    AmbiguousSubject {
114        /// The presenter's uid.
115        uid: u32,
116        /// Matching subjects.
117        subjects: Vec<SubjectName>,
118    },
119    /// The configured unauthenticated subject is absent or not an unauthenticated subject.
120    InvalidUnauthenticatedSubject {
121        /// The configured subject name.
122        subject: SubjectName,
123    },
124}
125
126/// Resolve a local request actor from captured peer information.
127pub fn resolve_local_actor(
128    policy: &ResolvedPolicy,
129    config: &Config,
130    peer: &PeerInfo,
131) -> Result<AuthenticatedActor, SubjectResolutionError> {
132    if let Some(uid) = peer.uid {
133        return resolve_unix_actor(policy, config, peer, uid);
134    }
135    resolve_unauthenticated_actor(policy, peer)
136}
137
138/// Resolve a Unix actor by uid with optional presenter context.
139pub fn resolve_unix_actor(
140    policy: &ResolvedPolicy,
141    config: &Config,
142    peer: &PeerInfo,
143    uid: u32,
144) -> Result<AuthenticatedActor, SubjectResolutionError> {
145    let gids: Vec<u32> = config
146        .groups_of(uid)
147        .map(|s| s.iter().copied().collect())
148        .unwrap_or_default();
149    let subjects: Vec<SubjectName> = matching_unix_subjects(policy, uid, &gids)
150        .into_iter()
151        .cloned()
152        .collect();
153    match subjects.as_slice() {
154        [] => Err(SubjectResolutionError::NoSubject { uid }),
155        [subject] => {
156            let mut actor = actor(subject.clone(), ProofKind::UnixPeerCredentials, peer);
157            actor.presenter.display_label = Some(config.user_name_num(uid));
158            Ok(actor)
159        }
160        _ => Err(SubjectResolutionError::AmbiguousSubject { uid, subjects }),
161    }
162}
163
164/// Resolve the configured unauthenticated actor, if enabled.
165pub fn resolve_unauthenticated_actor(
166    policy: &ResolvedPolicy,
167    peer: &PeerInfo,
168) -> Result<AuthenticatedActor, SubjectResolutionError> {
169    let Some(subject) = policy.unauthenticated_subject.as_ref() else {
170        return Err(SubjectResolutionError::MissingPeerCredentials);
171    };
172    if !policy
173        .subjects
174        .get(subject)
175        .is_some_and(|definition| definition.match_.matches_unauthenticated())
176    {
177        return Err(SubjectResolutionError::InvalidUnauthenticatedSubject {
178            subject: subject.clone(),
179        });
180    }
181    Ok(actor(subject.clone(), ProofKind::Unauthenticated, peer))
182}
183
184fn matching_unix_subjects<'a>(
185    policy: &'a ResolvedPolicy,
186    uid: u32,
187    gids: &[u32],
188) -> BTreeSet<&'a SubjectName> {
189    policy
190        .subjects
191        .iter()
192        .filter_map(|(name, subject)| subject.match_.matches_unix(uid, gids).then_some(name))
193        .collect()
194}
195
196fn actor(subject: SubjectName, kind: ProofKind, peer: &PeerInfo) -> AuthenticatedActor {
197    AuthenticatedActor {
198        authenticated_by: vec![ProofSummary {
199            kind,
200            subject: subject.clone(),
201        }],
202        subject,
203        presenter: PresenterInfo::from(peer),
204        transport: TransportInfo::default(),
205    }
206}
207
208#[cfg(test)]
209mod tests {
210    use std::collections::{BTreeMap, BTreeSet};
211
212    use super::*;
213    use crate::catalog::policy::{PrincipalSpec, ResolvedPolicy, SubjectDefinition, SubjectMatch};
214
215    fn unix_subject(uid: Option<u32>, gid: Option<u32>) -> SubjectDefinition {
216        SubjectDefinition {
217            break_glass: false,
218            match_: SubjectMatch::AllOf(vec![PrincipalSpec::Unix { uid, gid }]),
219        }
220    }
221
222    fn unauthenticated_subject() -> SubjectDefinition {
223        SubjectDefinition {
224            break_glass: false,
225            match_: SubjectMatch::AnyOf(vec![PrincipalSpec::Unauthenticated]),
226        }
227    }
228
229    fn peer(uid: Option<u32>, gid: Option<u32>) -> PeerInfo {
230        PeerInfo {
231            uid,
232            gid,
233            ..PeerInfo::default()
234        }
235    }
236
237    #[test]
238    fn local_unix_actor_resolves_exactly_one_subject() {
239        let policy = ResolvedPolicy {
240            subjects: BTreeMap::from([("svc.web".to_string(), unix_subject(Some(9001), None))]),
241            unauthenticated_subject: None,
242            rules: Vec::new(),
243        };
244        let actor =
245            resolve_local_actor(&policy, &Config::default(), &peer(Some(9001), Some(1))).unwrap();
246        assert_eq!(actor.subject, "svc.web");
247        assert_eq!(actor.unix_uid(), Some(9001));
248        assert_eq!(
249            actor.authenticated_by[0].kind,
250            ProofKind::UnixPeerCredentials
251        );
252    }
253
254    #[test]
255    fn group_subject_uses_configured_memberships_not_primary_gid() {
256        let policy = ResolvedPolicy {
257            subjects: BTreeMap::from([("ops.wheel".to_string(), unix_subject(None, Some(10)))]),
258            unauthenticated_subject: None,
259            rules: Vec::new(),
260        };
261        let mut config = Config::default();
262        config.memberships.insert(5000, BTreeSet::from([5000, 10]));
263        let actor = resolve_local_actor(&policy, &config, &peer(Some(5000), Some(5000))).unwrap();
264        assert_eq!(actor.subject, "ops.wheel");
265
266        let no_membership =
267            resolve_local_actor(&policy, &Config::default(), &peer(Some(5000), Some(10)));
268        assert!(matches!(
269            no_membership,
270            Err(SubjectResolutionError::NoSubject { uid: 5000 })
271        ));
272    }
273
274    #[test]
275    fn ambiguous_unix_actor_is_rejected() {
276        let policy = ResolvedPolicy {
277            subjects: BTreeMap::from([
278                ("svc.one".to_string(), unix_subject(Some(42), None)),
279                ("svc.two".to_string(), unix_subject(Some(42), None)),
280            ]),
281            unauthenticated_subject: None,
282            rules: Vec::new(),
283        };
284        let err = resolve_local_actor(&policy, &Config::default(), &peer(Some(42), None))
285            .expect_err("two subjects must be ambiguous");
286        assert!(matches!(
287            err,
288            SubjectResolutionError::AmbiguousSubject { uid: 42, .. }
289        ));
290    }
291
292    #[test]
293    fn unauthenticated_subject_resolves_only_when_configured() {
294        let policy = ResolvedPolicy {
295            subjects: BTreeMap::from([("guest".to_string(), unauthenticated_subject())]),
296            unauthenticated_subject: Some("guest".to_string()),
297            rules: Vec::new(),
298        };
299        let actor = resolve_local_actor(&policy, &Config::default(), &PeerInfo::default()).unwrap();
300        assert_eq!(actor.subject, "guest");
301        assert_eq!(actor.authenticated_by[0].kind, ProofKind::Unauthenticated);
302
303        let disabled = ResolvedPolicy {
304            unauthenticated_subject: None,
305            ..policy
306        };
307        assert!(matches!(
308            resolve_local_actor(&disabled, &Config::default(), &PeerInfo::default()),
309            Err(SubjectResolutionError::MissingPeerCredentials)
310        ));
311    }
312}