Skip to main content

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}