Skip to main content

prospero_core/
actor.rs

1//! The authenticated actor for the current request (#2), and the person that
2//! request says it is acting for (#251).
3//!
4//! `prospero-api`'s auth middleware runs each handler inside [`scope`]; the fleet
5//! emitter reads [`current`] when it builds an event, so events a request emits
6//! directly carry the token name without threading it through `FleetProvider`.
7//! Tasks spawned with `tokio::spawn` (the poll loop, watch loops, attach tasks)
8//! do not inherit it — their events stay unattributed, which is correct.
9//!
10//! [`current_subject`] is the second half, and the two are **not** the same kind
11//! of fact. The actor is who prosperod *authenticated*. The subject is who the
12//! client *says* it is acting for — a multi-user front end like Ariel holds one
13//! service token but serves many people. Prosperod cannot authenticate someone
14//! else's user and does not try; it records the claim beside the credential that
15//! made it, so an auditor reads "token `ariel` asserted this was for `U123`",
16//! never "prosperod verified `U123`".
17
18use std::future::Future;
19
20tokio::task_local! {
21    static ACTOR: Option<String>;
22    static SUBJECT: Option<String>;
23}
24
25/// Run `fut` with `actor` as the current actor.
26pub async fn scope<F: Future>(actor: Option<String>, fut: F) -> F::Output {
27    ACTOR.scope(actor, fut).await
28}
29
30/// The current actor, or `None` outside any [`scope`].
31pub fn current() -> Option<String> {
32    ACTOR.try_with(Clone::clone).ok().flatten()
33}
34
35/// Run `fut` with both the authenticated actor and the client's asserted
36/// subject in scope.
37pub async fn scope_with_subject<F: Future>(
38    actor: Option<String>,
39    subject: Option<String>,
40    fut: F,
41) -> F::Output {
42    SUBJECT.scope(subject, ACTOR.scope(actor, fut)).await
43}
44
45/// Who the current request says it is acting for, or `None`.
46///
47/// Client-asserted and unverified — see the module docs.
48pub fn current_subject() -> Option<String> {
49    SUBJECT.try_with(Clone::clone).ok().flatten()
50}
51
52/// Longest accepted subject. Bounds what a client can push into every event it
53/// causes; ids and handles are far shorter.
54pub const MAX_SUBJECT: usize = 128;
55
56/// Validate a client-asserted subject.
57///
58/// Rejects rather than sanitizes, so a client learns its value was wrong
59/// instead of discovering later that the audit trail says something else.
60/// Control characters are refused because this string is written into the
61/// event log and structured logs, where a newline would let a caller forge
62/// what looks like a second record.
63pub fn validate_subject(subject: &str) -> std::result::Result<&str, String> {
64    let trimmed = subject.trim();
65    if trimmed.is_empty() {
66        return Err("on-behalf-of must not be blank".to_string());
67    }
68    if trimmed.chars().count() > MAX_SUBJECT {
69        return Err(format!(
70            "on-behalf-of must be at most {MAX_SUBJECT} characters"
71        ));
72    }
73    if let Some(bad) = trimmed.chars().find(|c| c.is_control()) {
74        return Err(format!(
75            "on-behalf-of must not contain control characters (found {bad:?})"
76        ));
77    }
78    Ok(trimmed)
79}
80
81#[cfg(test)]
82mod tests {
83    use super::*;
84
85    /// #251: the subject reaches the event log and the structured logs, so a
86    /// newline in it could forge what reads as a separate record. Rejected,
87    /// not sanitized — a client that sent something wrong should be told.
88    #[test]
89    fn a_subject_that_could_forge_a_log_line_is_refused() {
90        assert!(validate_subject("discord:U123").is_ok());
91        assert_eq!(validate_subject("  U123  ").unwrap(), "U123", "trimmed");
92
93        assert!(validate_subject("").is_err(), "blank");
94        assert!(validate_subject("   ").is_err(), "whitespace only");
95        assert!(
96            validate_subject("alice\nactor=admin").is_err(),
97            "a newline must not be accepted"
98        );
99        assert!(validate_subject("alice\r\nx").is_err());
100        assert!(validate_subject("alice\tbob").is_err());
101        assert!(validate_subject("alice\u{0}").is_err());
102        assert!(
103            validate_subject(&"x".repeat(MAX_SUBJECT + 1)).is_err(),
104            "over-long must be refused"
105        );
106        assert!(validate_subject(&"x".repeat(MAX_SUBJECT)).is_ok());
107        // Non-ASCII names are people's names, not an attack.
108        assert!(validate_subject("Zoë Ω 日本").is_ok());
109    }
110
111    /// The two identities must be independently readable: an auditor needs to
112    /// see which credential acted *and* who it claimed to act for.
113    #[tokio::test]
114    async fn the_asserted_subject_is_separate_from_the_authenticated_actor() {
115        scope_with_subject(Some("ariel".into()), Some("discord:U123".into()), async {
116            assert_eq!(current().as_deref(), Some("ariel"));
117            assert_eq!(current_subject().as_deref(), Some("discord:U123"));
118        })
119        .await;
120
121        // A request that asserts nothing behaves exactly as before.
122        scope_with_subject(Some("ariel".into()), None, async {
123            assert_eq!(current().as_deref(), Some("ariel"));
124            assert_eq!(current_subject(), None);
125        })
126        .await;
127
128        assert_eq!(current_subject(), None, "does not leak past the scope");
129    }
130
131    #[tokio::test]
132    async fn a_spawned_task_inherits_neither_identity() {
133        scope_with_subject(Some("ariel".into()), Some("discord:U123".into()), async {
134            let seen = tokio::spawn(async { (current(), current_subject()) })
135                .await
136                .unwrap();
137            assert_eq!(
138                seen,
139                (None, None),
140                "background work must not be attributed to the request that started it"
141            );
142        })
143        .await;
144    }
145
146    #[tokio::test]
147    async fn current_is_none_outside_a_scope() {
148        assert_eq!(current(), None);
149    }
150
151    #[tokio::test]
152    async fn scope_is_visible_across_awaits_and_not_in_spawned_tasks() {
153        scope(Some("alice".into()), async {
154            tokio::task::yield_now().await;
155            assert_eq!(current().as_deref(), Some("alice"));
156            let spawned = tokio::spawn(async { current() }).await.unwrap();
157            assert_eq!(spawned, None, "spawned tasks must not inherit the actor");
158        })
159        .await;
160        assert_eq!(current(), None);
161    }
162}