1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
//! Request-scoped **current actor** — batteries-included "who did this" scope.
//!
//! This is the fourth request-scoped ambient value in Autumn, alongside the
//! [log context](crate::log::context), [tenancy](crate::tenancy), and the
//! per-request DB-timing accumulator. It answers a single question: *which
//! principal is responsible for the mutations happening right now?*
//!
//! Once the auth layer resolves an authenticated principal it publishes the
//! principal's id here via [`Current::set_actor`]. From that point on any code
//! in the request — most importantly the generated repository writes and audit
//! call sites — can read it ambiently with [`Current::actor`] without
//! re-extracting the session. Generated `#[repository(versioned)]` writes seed
//! [`MutationContext::actor`](crate::hooks::MutationContext::actor) from it, so
//! `VersionEntry.actor` records the authenticated user with **zero** per-call
//! wiring.
//!
//! # Layering
//!
//! - **Requests**: the log-context middleware establishes an empty actor scope
//! for the whole request; the three auth resolution points then call
//! [`Current::set_actor`] (mirroring [`log::context::set_user_id`]).
//! - **Background jobs / CLI / "on behalf of"**: wrap the work in
//! [`with_actor`] to set an explicit actor for that scope.
//! - **Unconfigured non-request contexts**: fall back to the process-wide
//! default actor token (see [`Current::set_default_actor`]); when that is
//! unset the actor is simply `None`, exactly as before this feature existed.
//!
//! Every read goes through [`tokio::task_local`]'s `try_with`, so reads outside
//! a scope never panic — they return the configured default (or `None`).
//!
//! [`log::context::set_user_id`]: crate::log::context::set_user_id
use std::future::Future;
use std::pin::Pin;
use std::sync::{Arc, RwLock};
use std::task::{Context, Poll};
use http_body::{Body as HttpBody, Frame, SizeHint};
use pin_project_lite::pin_project;
tokio::task_local! {
/// The current request's actor scope. Holds a cheap-to-clone,
/// interior-mutable handle so the auth layer can publish the resolved
/// principal *after* the scope is established (mirroring the log context).
static CURRENT_ACTOR: ActorScope;
}
/// Process-wide default actor token for non-request contexts (jobs, scheduler,
/// CLI). `None` by default, in which case out-of-scope reads yield `None` and
/// behavior is unchanged from before this feature.
static DEFAULT_ACTOR: RwLock<Option<String>> = RwLock::new(None);
/// A cheap-to-clone, interior-mutable handle to one request's current actor.
///
/// Cloning shares the same underlying storage, so the value published by the
/// auth layer via [`Current::set_actor`] after the scope was established is
/// observed by every later read within the request.
#[derive(Clone)]
pub(crate) struct ActorScope(Arc<RwLock<Option<String>>>);
impl ActorScope {
/// An actor scope with no actor yet resolved (the request default).
fn empty() -> Self {
Self(Arc::new(RwLock::new(None)))
}
/// An actor scope seeded with an explicit value (used by [`with_actor`]).
fn with_value(actor_id: Option<String>) -> Self {
Self(Arc::new(RwLock::new(actor_id)))
}
/// Read the current actor id, if any.
fn get(&self) -> Option<String> {
self.0.read().ok().and_then(|guard| guard.clone())
}
/// Publish (or clear) the actor id on this scope.
fn set(&self, actor_id: Option<String>) {
if let Ok(mut guard) = self.0.write() {
*guard = actor_id;
}
}
}
/// Public accessor for the ambient "current actor".
///
/// This is a zero-sized namespace type; all methods are associated functions.
#[derive(Debug, Clone, Copy)]
pub struct Current;
impl Current {
/// The id of the actor responsible for the work in the current scope.
///
/// Resolution order:
/// 1. an explicit actor set on the current scope (via [`with_actor`] or
/// [`Current::set_actor`]);
/// 2. `None` when inside a request scope that has not resolved a principal
/// (unauthenticated requests behave exactly as before);
/// 3. the process-wide default actor token when **no** scope is in effect
/// (background jobs / CLI / startup) — see [`Current::set_default_actor`].
///
/// Never panics: outside any scope it reads the default token, which is
/// `None` unless configured.
#[must_use]
pub fn actor() -> Option<String> {
match CURRENT_ACTOR.try_with(ActorScope::get) {
Ok(Some(actor_id)) => Some(actor_id),
Ok(None) => None,
Err(_) => default_actor(),
}
}
/// Publish the authenticated principal onto the current request scope.
///
/// Called by the auth layer right after it resolves the user id (next to
/// [`log::context::set_user_id`](crate::log::context::set_user_id)). No-op
/// when called outside of an established scope, so it is always safe.
pub fn set_actor(actor_id: impl Into<String>) {
let actor_id = actor_id.into();
let _ = CURRENT_ACTOR.try_with(|scope| scope.set(Some(actor_id)));
}
/// Set (or clear) the process-wide default actor token used for non-request
/// contexts. Pass `None` to restore the "no default" behavior.
///
/// This lets a job runner or scheduler attribute writes to a configured
/// identity (e.g. `"scheduler"`) instead of silently recording the
/// hard-coded `SYSTEM_ACTOR`.
pub fn set_default_actor(actor_id: Option<String>) {
if let Ok(mut guard) = DEFAULT_ACTOR.write() {
*guard = actor_id;
}
}
/// The currently configured process-wide default actor token, if any.
#[must_use]
pub fn default_actor() -> Option<String> {
default_actor()
}
}
fn default_actor() -> Option<String> {
DEFAULT_ACTOR.read().ok().and_then(|guard| guard.clone())
}
/// Run `future` with `actor_id` installed as the current actor.
///
/// The direct analog of [`tenancy::with_tenant`](crate::tenancy::with_tenant),
/// for background jobs, CLI tasks, and "acting on behalf of" flows. Nested
/// calls override the outer actor for the duration of the inner future.
pub async fn with_actor<F, R>(actor_id: impl Into<String>, future: F) -> R
where
F: Future<Output = R>,
{
CURRENT_ACTOR
.scope(ActorScope::with_value(Some(actor_id.into())), future)
.await
}
/// Establish an empty actor scope for a request future.
///
/// Used by the log-context middleware so the three auth resolution points can
/// publish the resolved principal via [`Current::set_actor`] after the request
/// future has started. Returns the scoping future directly so the middleware
/// can name it as a concrete `Future` type (no per-request box allocation).
pub(crate) fn scope_request<F: Future>(
future: F,
) -> tokio::task::futures::TaskLocalFuture<ActorScope, F> {
CURRENT_ACTOR.scope(ActorScope::empty(), future)
}
/// Clone the current request's actor-scope handle, if a scope is in effect.
///
/// The returned handle is the same cheap-to-clone, interior-mutable storage the
/// request scope holds, so re-entering it later — e.g. while producing a
/// lazy/streaming response body after the request future has resolved — observes
/// any actor the auth layer published during the request. Returns `None` outside
/// a scope. Mirrors [`log::context::current`](crate::log::context::current).
pub(crate) fn current_scope() -> Option<ActorScope> {
CURRENT_ACTOR.try_with(Clone::clone).ok()
}
pin_project! {
/// Response-body wrapper that re-establishes the request's current-actor
/// scope on every frame poll, so a `#[repository(versioned)]` write performed
/// while producing a lazy/streaming body (SSE, `Body::from_stream`) still
/// records the request's resolved actor instead of falling back to
/// `SYSTEM_ACTOR`. The sibling of [`LogContextBody`](crate::middleware::log_context)
/// and [`TenantPropagatingBody`](crate::tenancy::TenantPropagatingBody).
///
/// Carries the **resolved** [`ActorScope`] handle captured when the response
/// was produced (shared, interior-mutable storage from the request), so any
/// actor the auth layer set during the request is visible while streaming.
pub struct CurrentActorBody<B> {
#[pin]
inner: B,
// `None` only when the body was wrapped outside any request scope, in
// which case poll_frame is a transparent pass-through (never panics).
scope: Option<ActorScope>,
}
}
impl<B> CurrentActorBody<B> {
pub(crate) const fn new(inner: B, scope: Option<ActorScope>) -> Self {
Self { inner, scope }
}
}
impl<B: HttpBody> HttpBody for CurrentActorBody<B> {
type Data = B::Data;
type Error = B::Error;
fn poll_frame(
self: Pin<&mut Self>,
cx: &mut Context<'_>,
) -> Poll<Option<Result<Frame<Self::Data>, Self::Error>>> {
let this = self.project();
match this.scope.clone() {
Some(scope) => CURRENT_ACTOR.sync_scope(scope, || this.inner.poll_frame(cx)),
None => this.inner.poll_frame(cx),
}
}
fn is_end_stream(&self) -> bool {
self.inner.is_end_stream()
}
fn size_hint(&self) -> SizeHint {
self.inner.size_hint()
}
}
#[cfg(test)]
mod tests {
use super::*;
// Serialize tests that touch the process-wide default actor so they don't
// race each other (the default is global state).
static DEFAULT_GUARD: std::sync::Mutex<()> = std::sync::Mutex::new(());
#[tokio::test]
async fn actor_is_none_outside_scope_by_default() {
let _g = DEFAULT_GUARD.lock().unwrap();
Current::set_default_actor(None);
assert_eq!(Current::actor(), None);
}
#[tokio::test]
async fn with_actor_sets_the_ambient_actor() {
with_actor("u1", async {
assert_eq!(Current::actor(), Some("u1".to_owned()));
})
.await;
}
#[tokio::test]
async fn nested_with_actor_overrides_inner_and_restores_outer() {
with_actor("outer", async {
assert_eq!(Current::actor(), Some("outer".to_owned()));
with_actor("inner", async {
assert_eq!(Current::actor(), Some("inner".to_owned()));
})
.await;
// Back in the outer scope the outer actor is restored.
assert_eq!(Current::actor(), Some("outer".to_owned()));
})
.await;
}
#[tokio::test]
async fn set_actor_publishes_onto_an_established_scope() {
// Mirrors the request path: an empty scope is established, then the
// auth layer publishes the principal via set_actor.
scope_request(async {
assert_eq!(Current::actor(), None);
Current::set_actor("resolved-user");
assert_eq!(Current::actor(), Some("resolved-user".to_owned()));
})
.await;
}
#[tokio::test]
async fn set_actor_is_a_noop_outside_a_scope() {
let _g = DEFAULT_GUARD.lock().unwrap();
Current::set_default_actor(None);
// Must not panic and must not somehow persist a value.
Current::set_actor("nobody");
assert_eq!(Current::actor(), None);
}
#[tokio::test]
async fn default_actor_is_used_only_out_of_scope() {
// The default-dependent assertions touch process-global state, so guard
// them (and reset) without holding the lock across an `.await`.
{
let _g = DEFAULT_GUARD.lock().unwrap();
Current::set_default_actor(Some("scheduler".to_owned()));
// Out of scope: falls back to the configured default.
assert_eq!(Current::actor(), Some("scheduler".to_owned()));
// Reset before releasing the guard so no other test observes it.
Current::set_default_actor(None);
assert_eq!(Current::actor(), None);
}
// The scoped reads below do not depend on the process default: an empty
// request scope reads None, and an explicit scope overrides everything.
scope_request(async {
assert_eq!(Current::actor(), None);
})
.await;
with_actor("u1", async {
assert_eq!(Current::actor(), Some("u1".to_owned()));
})
.await;
}
#[test]
fn reads_never_panic_without_a_runtime_scope() {
// No tokio task-local scope in effect; must be inert, not panic.
let _ = Current::actor();
Current::set_actor("x");
}
#[tokio::test]
async fn captured_actor_scope_handle_is_reentrant() {
// Mirrors the streaming-body path: establish a request scope, let the
// auth layer publish the principal, and capture the interior-mutable
// scope handle while still inside the request.
let handle = scope_request(async {
Current::set_actor("streamed-user");
current_scope().expect("a scope is established here")
})
.await;
// The original request scope has now ended. Re-enter the captured handle
// from a DIFFERENT task-local frame (exactly what `CurrentActorBody` does
// on each `poll_frame`) and confirm the actor set during the request is
// still visible — so writes performed while streaming stay attributed.
let seen = CURRENT_ACTOR.sync_scope(handle, Current::actor);
assert_eq!(seen, Some("streamed-user".to_owned()));
}
}