Skip to content

Commit fbae4f8

Browse files
committed
refactor(server): introduce Authenticator trait and Principal enum
Replaces the hard-coded sandbox-method / dual-auth / Bearer branches in AuthGrpcRouter with a pluggable Authenticator chain that produces a Principal::{User, Sandbox, Anonymous}. The principal is inserted into request extensions for handler consumption. PR-1 keeps the legacy metadata marker for sandbox principals so existing handlers that read x-openshell-auth-source continue to work; the marker is removed in the PR-3 wire break. The OidcAuthenticator wraps the existing JwksCache::validate_token for User principals, and the LegacySandboxMarkerAuthenticator preserves the pre-refactor path-based behavior pending the gateway-minted JWT flow in PR 2/3. Part of the per-sandbox identity series that closes #1354.
1 parent dbba580 commit fbae4f8

7 files changed

Lines changed: 778 additions & 108 deletions

File tree

‎Cargo.lock‎

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎crates/openshell-server/Cargo.toml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ uuid = { workspace = true }
8282
hmac = "0.12"
8383
sha2 = { workspace = true }
8484
jsonwebtoken = { workspace = true }
85+
async-trait = "0.1"
8586
hex = "0.4"
8687
russh = "0.57"
8788
rand = { workspace = true }
Lines changed: 334 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,334 @@
1+
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
//! Pluggable authentication trait + chain dispatch.
5+
//!
6+
//! The gateway runs every authenticated request through an
7+
//! [`AuthenticatorChain`] of [`Authenticator`] implementations. The chain
8+
//! evaluates authenticators in order; the first one that recognizes the
9+
//! caller produces the [`Principal`]. An authenticator that does not apply
10+
//! (e.g. an OIDC authenticator seeing no Bearer header) returns `Ok(None)`
11+
//! so the chain falls through to the next. An authenticator that *does*
12+
//! apply but rejects the caller returns `Err(Status)`, which terminates
13+
//! the chain — fail-closed.
14+
//!
15+
//! This module is the abstraction PR (PR 1). Subsequent PRs slot in:
16+
//! - PR 2: `SandboxJwtAuthenticator` + `K8sServiceAccountAuthenticator`
17+
//! - PR 3: removal of the PR-1 legacy marker authenticator
18+
19+
use super::principal::{Principal, SandboxIdentitySource, SandboxPrincipal};
20+
use async_trait::async_trait;
21+
use std::sync::Arc;
22+
use tonic::Status;
23+
24+
/// Pluggable authentication step.
25+
///
26+
/// Implementations are expected to be cheap to clone (they live behind
27+
/// `Arc<dyn Authenticator>` inside an [`AuthenticatorChain`]).
28+
#[async_trait]
29+
pub trait Authenticator: Send + Sync + 'static {
30+
/// Inspect an inbound request and return the authenticated principal.
31+
///
32+
/// - `Ok(Some(principal))` — this authenticator recognized the caller.
33+
/// The chain stops and the principal is inserted into request
34+
/// extensions.
35+
/// - `Ok(None)` — this authenticator does not apply (e.g. no Bearer
36+
/// token for an OIDC authenticator). The chain falls through to
37+
/// the next authenticator.
38+
/// - `Err(status)` — this authenticator applies but rejected the
39+
/// caller. The chain terminates and the status is returned to the
40+
/// client. Fail-closed.
41+
async fn authenticate(
42+
&self,
43+
headers: &http::HeaderMap,
44+
path: &str,
45+
) -> Result<Option<Principal>, Status>;
46+
}
47+
48+
/// First-match-wins authenticator chain.
49+
///
50+
/// The chain owns its authenticators behind `Arc` so the entire chain is
51+
/// cheap to clone — required because `tower::Service::call` clones the
52+
/// router on every request.
53+
#[derive(Clone)]
54+
pub struct AuthenticatorChain {
55+
authenticators: Arc<[Arc<dyn Authenticator>]>,
56+
}
57+
58+
impl AuthenticatorChain {
59+
/// Build a chain from an ordered list of authenticators. Earlier
60+
/// entries are evaluated first.
61+
pub fn new(authenticators: Vec<Arc<dyn Authenticator>>) -> Self {
62+
Self {
63+
authenticators: Arc::from(authenticators),
64+
}
65+
}
66+
67+
/// Run the chain. Returns the first principal produced. If every
68+
/// authenticator returns `Ok(None)`, the result is `Ok(None)` — the
69+
/// router translates that to `unauthenticated`.
70+
pub async fn authenticate(
71+
&self,
72+
headers: &http::HeaderMap,
73+
path: &str,
74+
) -> Result<Option<Principal>, Status> {
75+
for authenticator in self.authenticators.iter() {
76+
if let Some(principal) = authenticator.authenticate(headers, path).await? {
77+
return Ok(Some(principal));
78+
}
79+
}
80+
Ok(None)
81+
}
82+
}
83+
84+
impl std::fmt::Debug for AuthenticatorChain {
85+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
86+
f.debug_struct("AuthenticatorChain")
87+
.field("len", &self.authenticators.len())
88+
.finish()
89+
}
90+
}
91+
92+
/// Authenticator that preserves the pre-refactor behavior for sandbox-class
93+
/// and dual-auth-no-Bearer paths.
94+
///
95+
/// Returns `Some(Principal::Sandbox)` with [`SandboxIdentitySource::LegacyMarker`]
96+
/// — the `sandbox_id` is left empty because no credential was verified. This
97+
/// matches the pre-PR-1 router which trusted the path list, not the caller.
98+
///
99+
/// PR 3 deletes this type once every sandbox call carries a gateway-minted
100+
/// JWT and the path-based branches are gone.
101+
pub struct LegacySandboxMarkerAuthenticator;
102+
103+
#[async_trait]
104+
impl Authenticator for LegacySandboxMarkerAuthenticator {
105+
async fn authenticate(
106+
&self,
107+
headers: &http::HeaderMap,
108+
path: &str,
109+
) -> Result<Option<Principal>, Status> {
110+
let is_sandbox_path = super::oidc::is_sandbox_method(path);
111+
let is_dual_no_bearer =
112+
super::oidc::is_dual_auth_method(path) && !has_bearer_token(headers);
113+
if is_sandbox_path || is_dual_no_bearer {
114+
return Ok(Some(Principal::Sandbox(SandboxPrincipal {
115+
sandbox_id: String::new(),
116+
source: SandboxIdentitySource::LegacyMarker,
117+
trust_domain: None,
118+
})));
119+
}
120+
Ok(None)
121+
}
122+
}
123+
124+
fn has_bearer_token(headers: &http::HeaderMap) -> bool {
125+
headers
126+
.get("authorization")
127+
.and_then(|v| v.to_str().ok())
128+
.is_some_and(|v| v.starts_with("Bearer "))
129+
}
130+
131+
#[cfg(test)]
132+
pub mod test_support {
133+
use super::*;
134+
use std::sync::Mutex;
135+
136+
/// Authenticator that always returns the configured outcome. Used by
137+
/// tests to inject a known principal (or rejection) without running real
138+
/// crypto. Each call records the path it was invoked with so tests can
139+
/// assert chain ordering.
140+
pub struct MockAuthenticator {
141+
pub outcome: Result<Option<Principal>, Status>,
142+
pub calls: Mutex<Vec<String>>,
143+
}
144+
145+
impl MockAuthenticator {
146+
pub fn returning(outcome: Result<Option<Principal>, Status>) -> Self {
147+
Self {
148+
outcome,
149+
calls: Mutex::new(Vec::new()),
150+
}
151+
}
152+
153+
pub fn call_count(&self) -> usize {
154+
self.calls.lock().unwrap().len()
155+
}
156+
}
157+
158+
#[async_trait]
159+
impl Authenticator for MockAuthenticator {
160+
async fn authenticate(
161+
&self,
162+
_headers: &http::HeaderMap,
163+
path: &str,
164+
) -> Result<Option<Principal>, Status> {
165+
self.calls.lock().unwrap().push(path.to_string());
166+
self.outcome.clone()
167+
}
168+
}
169+
}
170+
171+
#[cfg(test)]
172+
mod tests {
173+
use super::test_support::MockAuthenticator;
174+
use super::*;
175+
use crate::auth::identity::{Identity, IdentityProvider};
176+
use crate::auth::principal::UserPrincipal;
177+
178+
fn user_principal(subject: &str) -> Principal {
179+
Principal::User(UserPrincipal {
180+
identity: Identity {
181+
subject: subject.to_string(),
182+
display_name: None,
183+
roles: vec![],
184+
scopes: vec![],
185+
provider: IdentityProvider::Oidc,
186+
},
187+
})
188+
}
189+
190+
#[tokio::test]
191+
async fn chain_returns_first_match() {
192+
let first = Arc::new(MockAuthenticator::returning(Ok(Some(user_principal(
193+
"alice",
194+
)))));
195+
let second = Arc::new(MockAuthenticator::returning(Ok(Some(user_principal(
196+
"bob",
197+
)))));
198+
let chain = AuthenticatorChain::new(vec![first.clone(), second.clone()]);
199+
let result = chain
200+
.authenticate(&http::HeaderMap::new(), "/some/path")
201+
.await
202+
.unwrap()
203+
.expect("expected a principal");
204+
match result {
205+
Principal::User(u) => assert_eq!(u.identity.subject, "alice"),
206+
_ => panic!("expected user principal"),
207+
}
208+
assert_eq!(first.call_count(), 1);
209+
assert_eq!(
210+
second.call_count(),
211+
0,
212+
"second authenticator must be skipped after first matches"
213+
);
214+
}
215+
216+
#[tokio::test]
217+
async fn chain_falls_through_on_none() {
218+
let first = Arc::new(MockAuthenticator::returning(Ok(None)));
219+
let second = Arc::new(MockAuthenticator::returning(Ok(Some(user_principal(
220+
"bob",
221+
)))));
222+
let chain = AuthenticatorChain::new(vec![first.clone(), second.clone()]);
223+
let result = chain
224+
.authenticate(&http::HeaderMap::new(), "/some/path")
225+
.await
226+
.unwrap()
227+
.expect("expected a principal");
228+
match result {
229+
Principal::User(u) => assert_eq!(u.identity.subject, "bob"),
230+
_ => panic!("expected user principal"),
231+
}
232+
assert_eq!(first.call_count(), 1);
233+
assert_eq!(second.call_count(), 1);
234+
}
235+
236+
#[tokio::test]
237+
async fn chain_fails_closed_on_first_error() {
238+
let first = Arc::new(MockAuthenticator::returning(Err(Status::unauthenticated(
239+
"bad token",
240+
))));
241+
let second = Arc::new(MockAuthenticator::returning(Ok(Some(user_principal(
242+
"bob",
243+
)))));
244+
let chain = AuthenticatorChain::new(vec![first.clone(), second.clone()]);
245+
let err = chain
246+
.authenticate(&http::HeaderMap::new(), "/some/path")
247+
.await
248+
.expect_err("must short-circuit on error");
249+
assert_eq!(err.code(), tonic::Code::Unauthenticated);
250+
assert_eq!(first.call_count(), 1);
251+
assert_eq!(
252+
second.call_count(),
253+
0,
254+
"must not consult later authenticators after an error"
255+
);
256+
}
257+
258+
#[tokio::test]
259+
async fn empty_chain_returns_none() {
260+
let chain = AuthenticatorChain::new(vec![]);
261+
let result = chain
262+
.authenticate(&http::HeaderMap::new(), "/some/path")
263+
.await
264+
.unwrap();
265+
assert!(result.is_none());
266+
}
267+
268+
#[tokio::test]
269+
async fn legacy_marker_recognizes_sandbox_method() {
270+
let auth = LegacySandboxMarkerAuthenticator;
271+
let result = auth
272+
.authenticate(
273+
&http::HeaderMap::new(),
274+
"/openshell.v1.OpenShell/ReportPolicyStatus",
275+
)
276+
.await
277+
.unwrap()
278+
.expect("sandbox path must produce a principal");
279+
match result {
280+
Principal::Sandbox(p) => {
281+
assert!(p.sandbox_id.is_empty(), "legacy marker has no verified id");
282+
assert!(matches!(p.source, SandboxIdentitySource::LegacyMarker));
283+
}
284+
_ => panic!("expected sandbox principal"),
285+
}
286+
}
287+
288+
#[tokio::test]
289+
async fn legacy_marker_recognizes_dual_auth_without_bearer() {
290+
let auth = LegacySandboxMarkerAuthenticator;
291+
let result = auth
292+
.authenticate(
293+
&http::HeaderMap::new(),
294+
"/openshell.v1.OpenShell/UpdateConfig",
295+
)
296+
.await
297+
.unwrap();
298+
assert!(
299+
result.is_some(),
300+
"dual-auth without Bearer must mark sandbox"
301+
);
302+
}
303+
304+
#[tokio::test]
305+
async fn legacy_marker_yields_to_dual_auth_with_bearer() {
306+
let auth = LegacySandboxMarkerAuthenticator;
307+
let mut headers = http::HeaderMap::new();
308+
headers.insert(
309+
"authorization",
310+
http::HeaderValue::from_static("Bearer xyz"),
311+
);
312+
let result = auth
313+
.authenticate(&headers, "/openshell.v1.OpenShell/UpdateConfig")
314+
.await
315+
.unwrap();
316+
assert!(
317+
result.is_none(),
318+
"dual-auth WITH Bearer must fall through to the OIDC authenticator"
319+
);
320+
}
321+
322+
#[tokio::test]
323+
async fn legacy_marker_skips_unrelated_paths() {
324+
let auth = LegacySandboxMarkerAuthenticator;
325+
let result = auth
326+
.authenticate(
327+
&http::HeaderMap::new(),
328+
"/openshell.v1.OpenShell/ListSandboxes",
329+
)
330+
.await
331+
.unwrap();
332+
assert!(result.is_none());
333+
}
334+
}

‎crates/openshell-server/src/auth/mod.rs‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,11 @@
88
//! - `identity`: Provider-agnostic identity representation
99
//! - `http`: HTTP endpoints for auth discovery and token exchange
1010
11+
pub mod authenticator;
1112
pub mod authz;
1213
mod http;
1314
pub mod identity;
1415
pub mod oidc;
16+
pub mod principal;
1517

1618
pub use http::router;

0 commit comments

Comments
 (0)