harn_vm/harness_auth.rs
1//! Ambient authenticated-principal scope threaded into `.harn` callees by
2//! hosts that authenticate a request before dispatch (today: `harn-serve`,
3//! which resolves an [`crate::auth`-style] principal — subject, scheme,
4//! granted scopes, and an optional embedder-assigned `kind` — at admission).
5//!
6//! Exposed to scripts as the `harness.auth` sub-handle:
7//!
8//! ```harn
9//! if !harness.auth.has_scope("admin:dlq:write") { return forbidden(req) }
10//! let actor = harness.auth.subject()
11//! ```
12//!
13//! The handle is **read-only identity** — it carries only the generic
14//! principal facts harn-serve itself authenticated (subject, scheme,
15//! granted scopes, an optional principal `kind`). It deliberately does NOT
16//! expose the tenant (that stays the single-sourced [`harness.tenant`]
17//! ambient, see [`crate::harness_tenant`]) and never carries credentials,
18//! secrets, or product-specific authorization concepts: a `.harn` policy
19//! helper composes these facts with `harness.tenant` and the route context
20//! to decide admission, so the language core stays about principals and
21//! scopes, not products.
22//!
23//! Method semantics:
24//! - `is_authenticated()` — whether the host bound a principal at all.
25//! - `subject()` / `scheme()` — the authenticated subject and auth scheme;
26//! raise a typed [`ErrorCategory::Auth`] error when no principal is bound
27//! (mirroring `harness.tenant.id()`). `try_subject()` / `try_scheme()`
28//! return `nil` instead so callers can branch without try/catch.
29//! - `kind()` — the optional principal classification the host assigned
30//! (e.g. `"operator"`, `"tenant"`, `"worker"`); `nil` when unset, even for
31//! an authenticated principal, so it is inherently a `try`-shaped getter.
32//! - `scopes()` — the granted scope set as a sorted list (empty when no
33//! principal is bound — an unauthenticated caller has granted nothing).
34//! - `has_scope(scope)` — membership test against `scopes()`; `false` when
35//! no principal is bound.
36//!
37//! The scope is stack-shaped (push/pop via [`enter_auth_principal`]) so
38//! nested dispatches (a callee that re-enters the dispatcher under a
39//! different principal) restore the outer principal on return, exactly like
40//! [`crate::harness_tenant`] and [`crate::observability::request_id`].
41
42use std::cell::RefCell;
43use std::collections::BTreeSet;
44use std::sync::Arc;
45
46/// The authenticated principal a host bound for the duration of a
47/// dispatch. Carries only generic identity facts harn-serve authenticated;
48/// never secrets, tenant (see [`crate::harness_tenant`]), or product
49/// authorization concepts.
50#[derive(Clone, Debug, Default, PartialEq, Eq)]
51pub struct AuthPrincipal {
52 /// Stable identifier for the authenticated subject (e.g. an API-key id,
53 /// OAuth `sub`, or worker token id). Empty string is treated as "no
54 /// subject" by the getters but a bound principal should always set it.
55 pub subject: String,
56 /// Auth scheme that admitted the request (e.g. `"apikey"`, `"oauth"`,
57 /// `"hmac"`). Lets a policy gate on credential class.
58 pub scheme: String,
59 /// Scopes the credential carries — the same set harn-serve checked the
60 /// route's `@scopes` against. Sorted/deduped via `BTreeSet`.
61 pub scopes: BTreeSet<String>,
62 /// Optional principal classification the host assigned (e.g.
63 /// `"operator"` vs `"tenant"` vs `"worker"`). Generic — harn-serve does
64 /// not interpret it; policies match against it for "allowed principal
65 /// kinds". `None` when the host did not classify the principal.
66 pub kind: Option<String>,
67}
68
69thread_local! {
70 static ACTIVE_PRINCIPAL_STACK: RefCell<Vec<Arc<AuthPrincipal>>> =
71 const { RefCell::new(Vec::new()) };
72}
73
74/// RAII guard returned by [`enter_auth_principal`]. Popping the stack on
75/// drop keeps the ambient scope balanced even when the dispatched callable
76/// panics or returns an error.
77#[must_use = "dropping the guard immediately pops the auth-principal scope"]
78pub struct AuthPrincipalScopeGuard {
79 _private: (),
80}
81
82impl Drop for AuthPrincipalScopeGuard {
83 fn drop(&mut self) {
84 ACTIVE_PRINCIPAL_STACK.with(|stack| {
85 stack.borrow_mut().pop();
86 });
87 }
88}
89
90/// Push `principal` onto the ambient stack for the lifetime of the
91/// returned guard. The innermost entry wins for [`current_auth_principal`].
92pub fn enter_auth_principal(principal: AuthPrincipal) -> AuthPrincipalScopeGuard {
93 ACTIVE_PRINCIPAL_STACK.with(|stack| stack.borrow_mut().push(Arc::new(principal)));
94 AuthPrincipalScopeGuard { _private: () }
95}
96
97/// Currently-active authenticated principal, or `None` when the host
98/// dispatched without authenticating one. The innermost
99/// [`enter_auth_principal`] scope wins.
100pub fn current_auth_principal() -> Option<Arc<AuthPrincipal>> {
101 ACTIVE_PRINCIPAL_STACK.with(|stack| stack.borrow().last().cloned())
102}
103
104/// Standard message raised by `harness.auth.subject()` /
105/// `harness.auth.scheme()` when no principal is bound. Lives here (and not
106/// inline at the call site) so adapters and tests can assert against one
107/// canonical string.
108pub const MISSING_PRINCIPAL_MESSAGE: &str =
109 "harness.auth: no principal bound to this dispatch — the host did not authenticate the request";
110
111#[cfg(test)]
112mod tests {
113 use super::*;
114
115 fn principal(subject: &str, scopes: &[&str]) -> AuthPrincipal {
116 AuthPrincipal {
117 subject: subject.to_string(),
118 scheme: "apikey".to_string(),
119 scopes: scopes.iter().map(|s| s.to_string()).collect(),
120 kind: Some("operator".to_string()),
121 }
122 }
123
124 #[test]
125 fn current_returns_none_when_nothing_pushed() {
126 assert!(current_auth_principal().is_none());
127 }
128
129 #[test]
130 fn guard_pops_on_drop_and_inner_scope_shadows_outer() {
131 let outer = enter_auth_principal(principal("outer", &["read:a"]));
132 assert_eq!(
133 current_auth_principal().map(|p| p.subject.clone()),
134 Some("outer".to_string())
135 );
136 {
137 let _inner = enter_auth_principal(principal("inner", &["read:b"]));
138 assert_eq!(
139 current_auth_principal().map(|p| p.subject.clone()),
140 Some("inner".to_string())
141 );
142 }
143 assert_eq!(
144 current_auth_principal().map(|p| p.subject.clone()),
145 Some("outer".to_string())
146 );
147 drop(outer);
148 assert!(current_auth_principal().is_none());
149 }
150}