Skip to content

Commit ae5eb28

Browse files
committed
feat(auth): add certificate-bound discovery readers and workspace scopes
Implement discovery-only certificate grants, bounded single/set/all workspace views, and live grant and certificate expiry checks. Preserve existing user authorization and reject discovery credentials on other APIs and application requests. Closes #4668 Signed-off-by: Taylor Mutch <taylormutch@gmail.com>
1 parent fea58fc commit ae5eb28

23 files changed

Lines changed: 1072 additions & 45 deletions

File tree

‎crates/openshell-core/src/config.rs‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -541,6 +541,9 @@ pub struct MtlsAuthConfig {
541541
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
542542
#[serde(deny_unknown_fields)]
543543
pub struct GatewayAuthConfig {
544+
/// Operator-managed JSON discovery certificate grant file, reloaded on requests/watches.
545+
#[serde(default)]
546+
pub discovery_grants_file: Option<PathBuf>,
544547
/// When true, unauthenticated user/CLI calls are accepted as a local
545548
/// developer principal. This is an unsafe local-development escape hatch
546549
/// for trusted, non-shared gateways. Sandbox supervisor calls still use

‎crates/openshell-server/README.md‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -327,3 +327,20 @@ URL responses and discovery agree with that installed name. A mode or gateway
327327
name change changes configuration identity, withdrawing old discovery evidence
328328
until the supervisor acknowledges the replacement. Hash inputs and encoding are
329329
shared with gateway-mediated routing in `openshell-core::app_listener`.
330+
331+
Discovery accepts an explicit workspace, up to 64 distinct `workspaces`, or the
332+
existing all-workspaces selector. Omission and combined selectors fail.
333+
Workspace users retain role/scope/membership authorization for every requested
334+
workspace; all-workspaces access requires platform administration or a dedicated
335+
certificate grant. Discovery certificates carry `OU=discovery`, use the configured
336+
TLS client CA, and receive authority only through the operator-managed fingerprint
337+
mapping at `gateway.auth.discovery_grants_file`. They never become user/operator
338+
principals. Certificate expiry and grant removal are rechecked on active watches.
339+
340+
All-workspaces collection includes future active workspaces and omits terminating
341+
ones, with a 64-workspace scan bound. Selected-workspace collection does not scan
342+
unrelated workspaces. Combined snapshots retain the 4,096-route/4-MiB output bound
343+
and ten-second collection deadline; each workspace scan retains its existing
344+
1,000-record/4-MiB input bounds. Overflow and storage failures are errors, never
345+
partial successful views. A workspace-set change during collection requires a
346+
retry; this is not a cross-resource transactional snapshot.

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

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -216,7 +216,8 @@ fn authenticated(principal: &Principal) -> bool {
216216
match principal {
217217
Principal::User(user) => user.identity.provider != IdentityProvider::LocalDev,
218218
Principal::Anonymous => false,
219-
Principal::Operator(_)
219+
Principal::Discovery(_)
220+
| Principal::Operator(_)
220221
| Principal::Sandbox(_)
221222
| Principal::Peer(_)
222223
| Principal::Supervisor(_) => true,
@@ -226,7 +227,7 @@ fn authenticated(principal: &Principal) -> bool {
226227
/// The mechanism that established `principal`, when it has an audit label.
227228
fn mechanism(principal: &Principal) -> Option<AuthMechanism> {
228229
match principal {
229-
Principal::Operator(_) => Some(AuthMechanism::Mtls),
230+
Principal::Discovery(_) | Principal::Operator(_) => Some(AuthMechanism::Mtls),
230231
Principal::User(user) => match user.identity.provider {
231232
IdentityProvider::Oidc => Some(AuthMechanism::Oidc),
232233
IdentityProvider::Mtls => Some(AuthMechanism::Mtls),
@@ -253,6 +254,7 @@ fn credential_digest(
253254
peer_certificate_sha256: Option<&str>,
254255
) -> Option<[u8; 32]> {
255256
let credential = match principal {
257+
Principal::Discovery(reader) => reader.certificate_sha256.as_str(),
256258
Principal::Operator(operator) => operator.certificate_sha256.as_str(),
257259
Principal::User(user) if user.identity.provider == IdentityProvider::Mtls => {
258260
peer_certificate_sha256.unwrap_or_default()
@@ -513,6 +515,7 @@ fn principal_user(principal: &Principal) -> User {
513515
Principal::Supervisor(supervisor) => {
514516
User::other(&supervisor.identity.subject, "Supervisor")
515517
}
518+
Principal::Discovery(reader) => User::other(&reader.certificate_sha256, "Discovery Reader"),
516519
Principal::Anonymous => User::unidentified(),
517520
}
518521
}
Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
//! Operator-managed grants for verified discovery client certificates.
5+
6+
use super::{
7+
identity::{Identity, IdentityProvider},
8+
principal::DiscoveryPrincipal,
9+
};
10+
use serde::Deserialize;
11+
use std::collections::BTreeSet;
12+
use std::path::Path;
13+
use tokio::io::AsyncReadExt as _;
14+
use tonic::Status;
15+
16+
pub const MAX_WORKSPACES: usize = 64;
17+
const MAX_FILE_BYTES: usize = 256 * 1024;
18+
19+
/// Certificate OU marks a restricted credential class, never an authority grant.
20+
/// The separate operator-controlled fingerprint mapping supplies authority.
21+
pub fn is_certificate(identity: &Identity) -> bool {
22+
identity.provider == IdentityProvider::Mtls && identity.roles.iter().any(|r| r == "discovery")
23+
}
24+
25+
pub fn is_method(path: &str) -> bool {
26+
matches!(
27+
path,
28+
"/openshell.v1.OpenShell/GetServiceRouteSnapshot"
29+
| "/openshell.v1.OpenShell/WatchServiceRoutes"
30+
) && super::method_authz::lookup(path).is_some_and(|entry| {
31+
entry.auth_mode == super::method_authz::AuthMode::Bearer
32+
&& entry.scope.as_deref() == Some("sandbox:read")
33+
&& entry.workspace_role.as_deref() == Some("user")
34+
&& entry.global_role.is_none()
35+
})
36+
}
37+
38+
#[derive(Deserialize)]
39+
#[serde(deny_unknown_fields)]
40+
struct Grants {
41+
readers: Vec<Grant>,
42+
}
43+
44+
#[derive(Deserialize)]
45+
#[serde(deny_unknown_fields)]
46+
pub struct Grant {
47+
pub certificate_sha256: String,
48+
#[serde(default)]
49+
pub workspaces: Vec<String>,
50+
#[serde(default)]
51+
pub all_workspaces: bool,
52+
}
53+
54+
pub async fn grant(path: Option<&Path>, principal: &DiscoveryPrincipal) -> Result<Grant, Status> {
55+
tokio::time::timeout(
56+
std::time::Duration::from_secs(2),
57+
load_grant(path, principal),
58+
)
59+
.await
60+
.map_err(|_| Status::unavailable("discovery grant read timed out"))?
61+
}
62+
63+
async fn load_grant(path: Option<&Path>, principal: &DiscoveryPrincipal) -> Result<Grant, Status> {
64+
if principal.expires_at_ms <= crate::persistence::current_time_ms() {
65+
return Err(Status::unauthenticated("discovery certificate expired"));
66+
}
67+
let path = path.ok_or_else(|| Status::permission_denied("discovery grant required"))?;
68+
let file = tokio::fs::File::open(path)
69+
.await
70+
.map_err(|_| Status::unavailable("discovery grants unavailable"))?;
71+
let mut bytes = Vec::new();
72+
file.take(
73+
u64::try_from(MAX_FILE_BYTES + 1).map_err(|_| Status::internal("invalid file limit"))?,
74+
)
75+
.read_to_end(&mut bytes)
76+
.await
77+
.map_err(|_| Status::unavailable("discovery grants unavailable"))?;
78+
if bytes.len() > MAX_FILE_BYTES {
79+
return Err(Status::resource_exhausted(
80+
"discovery grants file too large",
81+
));
82+
}
83+
let grants: Grants = serde_json::from_slice(&bytes)
84+
.map_err(|_| Status::unavailable("invalid discovery grants"))?;
85+
if grants.readers.len() > 1024 {
86+
return Err(Status::resource_exhausted("too many discovery grants"));
87+
}
88+
let mut fingerprints = BTreeSet::new();
89+
for reader in &grants.readers {
90+
if reader.certificate_sha256.len() != 64
91+
|| !reader
92+
.certificate_sha256
93+
.bytes()
94+
.all(|c| c.is_ascii_digit() || (b'a'..=b'f').contains(&c))
95+
|| !fingerprints.insert(&reader.certificate_sha256)
96+
|| reader.workspaces.len() > MAX_WORKSPACES
97+
|| reader.all_workspaces != reader.workspaces.is_empty()
98+
{
99+
return Err(Status::unavailable("invalid discovery grant mapping"));
100+
}
101+
let mut names = BTreeSet::new();
102+
for workspace in &reader.workspaces {
103+
crate::grpc::workspace::validate_workspace_name(workspace)
104+
.map_err(|_| Status::unavailable("invalid discovery workspace grant"))?;
105+
if !names.insert(workspace) {
106+
return Err(Status::unavailable("duplicate discovery workspace grant"));
107+
}
108+
}
109+
}
110+
grants
111+
.readers
112+
.into_iter()
113+
.find(|g| g.certificate_sha256 == principal.certificate_sha256)
114+
.ok_or_else(|| Status::permission_denied("discovery grant revoked or missing"))
115+
}
116+
117+
#[cfg(test)]
118+
mod tests {
119+
use super::*;
120+
#[tokio::test]
121+
async fn fingerprint_grants_reload_and_never_derive_authority_from_subject_roles() {
122+
let dir = tempfile::tempdir().unwrap();
123+
let path = dir.path().join("grants.json");
124+
let reader = DiscoveryPrincipal {
125+
certificate_sha256: "a".repeat(64),
126+
expires_at_ms: crate::persistence::current_time_ms() + 60_000,
127+
};
128+
let write = |readers: serde_json::Value| {
129+
std::fs::write(
130+
&path,
131+
serde_json::to_vec(&serde_json::json!({"readers":readers})).unwrap(),
132+
)
133+
.unwrap();
134+
};
135+
write(
136+
serde_json::json!([{"certificate_sha256":reader.certificate_sha256,"workspaces":["default"]}]),
137+
);
138+
assert_eq!(
139+
grant(Some(&path), &reader).await.unwrap().workspaces,
140+
vec!["default"]
141+
);
142+
let unrelated = DiscoveryPrincipal {
143+
certificate_sha256: "b".repeat(64),
144+
..reader.clone()
145+
};
146+
assert_eq!(
147+
grant(Some(&path), &unrelated).await.err().unwrap().code(),
148+
tonic::Code::PermissionDenied
149+
);
150+
write(serde_json::json!([]));
151+
assert_eq!(
152+
grant(Some(&path), &reader).await.err().unwrap().code(),
153+
tonic::Code::PermissionDenied
154+
);
155+
write(
156+
serde_json::json!([{"certificate_sha256":reader.certificate_sha256,"all_workspaces":true,"workspaces":["default"]}]),
157+
);
158+
assert_eq!(
159+
grant(Some(&path), &reader).await.err().unwrap().code(),
160+
tonic::Code::Unavailable
161+
);
162+
assert_eq!(
163+
grant(
164+
Some(&path),
165+
&DiscoveryPrincipal {
166+
expires_at_ms: 0,
167+
..reader
168+
}
169+
)
170+
.await
171+
.err()
172+
.unwrap()
173+
.code(),
174+
tonic::Code::Unauthenticated
175+
);
176+
}
177+
#[test]
178+
fn every_other_rpc_is_denied_including_unclassified_methods() {
179+
for path in super::super::method_authz::all_paths() {
180+
assert_eq!(
181+
is_method(path),
182+
matches!(
183+
path,
184+
"/openshell.v1.OpenShell/GetServiceRouteSnapshot"
185+
| "/openshell.v1.OpenShell/WatchServiceRoutes"
186+
),
187+
"{path}"
188+
);
189+
}
190+
assert!(!is_method("/openshell.v1.OpenShell/FutureReadMethod"));
191+
}
192+
}

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

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ use tonic::Status;
3030
pub fn ensure_sandbox_scope(principal: &Principal, claimed_sandbox_id: &str) -> Result<(), Status> {
3131
match principal {
3232
Principal::User(_) | Principal::Operator(_) => Ok(()),
33-
Principal::Peer(_) | Principal::Supervisor(_) => Err(deny(
33+
Principal::Discovery(_) | Principal::Peer(_) | Principal::Supervisor(_) => Err(deny(
3434
principal,
3535
AuthzDenial::MethodNotPermitted,
3636
"gateway peer and supervisor principals may not call sandbox-scoped methods",
@@ -88,7 +88,8 @@ pub fn ensure_sandbox_principal_scope(
8888
ensure_sandbox_scope(principal, claimed_sandbox_id)?;
8989
Ok(p.clone())
9090
}
91-
Principal::User(_)
91+
Principal::Discovery(_)
92+
| Principal::User(_)
9293
| Principal::Operator(_)
9394
| Principal::Peer(_)
9495
| Principal::Supervisor(_) => Err(deny(

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ pub mod authenticator;
1313
pub mod authz;
1414
pub mod compute_driver;
1515
pub mod descriptor_authz;
16+
pub mod discovery;
1617
pub mod extension_mint_limit;
1718
pub mod guard;
1819
mod http;

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ use openshell_core::jwt::CredentialEpoch;
2323
/// retrieve it via `req.extensions().get::<Principal>()`.
2424
#[derive(Debug, Clone)]
2525
pub enum Principal {
26+
/// Restricted verified certificate; authority is reloaded from an administrator grant.
27+
Discovery(DiscoveryPrincipal),
2628
/// Human caller authenticated via OIDC (Keycloak, Entra ID, Okta, etc.).
2729
User(UserPrincipal),
2830
/// Gateway-wide administrator authenticated only by a verified operator certificate.
@@ -59,6 +61,13 @@ pub struct OperatorPrincipal {
5961
pub certificate_sha256: String,
6062
}
6163

64+
/// Verified TLS discovery identity. No general user, role, or application authority.
65+
#[derive(Debug, Clone)]
66+
pub struct DiscoveryPrincipal {
67+
pub certificate_sha256: String,
68+
pub expires_at_ms: i64,
69+
}
70+
6271
impl Principal {
6372
/// Identity of a human/API caller, preserving its distinct principal variant.
6473
pub fn user_identity(&self) -> Option<&Identity> {
@@ -148,6 +157,7 @@ impl Principal {
148157
Self::Sandbox(sandbox) => format!("sandbox:{}", sandbox.sandbox_id),
149158
Self::Peer(peer) => format!("peer:{}", peer.replica_id),
150159
Self::Supervisor(supervisor) => format!("supervisor:{}", supervisor.identity.subject),
160+
Self::Discovery(reader) => format!("discovery:{}", reader.certificate_sha256),
151161
Self::Anonymous => "anonymous".to_string(),
152162
}
153163
}

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,15 @@ async fn authorize_with_chain(
5757
workspace: &str,
5858
chain: Option<AuthenticatorChain>,
5959
) -> Result<Principal, Status> {
60+
if peer
61+
.identity
62+
.as_ref()
63+
.is_some_and(super::discovery::is_certificate)
64+
{
65+
return Err(Status::permission_denied(
66+
"discovery certificates cannot access applications",
67+
));
68+
}
6069
let origin = RequestOrigin::new(peer.source, headers);
6170
let principal = authenticate(state, headers, peer, chain).await.map_err(
6271
|(status, mechanism, reason)| {

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -207,7 +207,7 @@ pub async fn authorize_workspace(
207207
workspace,
208208
grant: AuthGrant::Sandbox,
209209
}),
210-
Principal::Peer(_) | Principal::Supervisor(_) => Err(deny(
210+
Principal::Discovery(_) | Principal::Peer(_) | Principal::Supervisor(_) => Err(deny(
211211
principal,
212212
AuthzDenial::MethodNotPermitted,
213213
"gateway peer and supervisor principals cannot perform workspace operations",
@@ -244,7 +244,7 @@ pub fn require_platform_admin(admin_role: &str, principal: &Principal) -> Result
244244
}
245245
Principal::User(_) => "platform admin role required for cross-workspace operations",
246246
Principal::Sandbox(_) => "sandbox principals cannot perform cross-workspace operations",
247-
Principal::Peer(_) | Principal::Supervisor(_) => {
247+
Principal::Discovery(_) | Principal::Peer(_) | Principal::Supervisor(_) => {
248248
"gateway peer and supervisor principals cannot perform cross-workspace operations"
249249
}
250250
Principal::Anonymous => return Err(Status::unauthenticated("authentication required")),

‎crates/openshell-server/src/cli.rs‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -737,7 +737,20 @@ fn prepare_server_config_with_drivers(
737737

738738
/// Reject an implicit development gateway before starting any listeners or services.
739739
fn validate_user_auth(config: &openshell_core::Config) -> Result<()> {
740+
if config.auth.discovery_grants_file.is_some()
741+
&& config
742+
.tls
743+
.as_ref()
744+
.and_then(|tls| tls.client_ca_path.as_ref())
745+
.is_none()
746+
{
747+
return Err(miette::miette!(
748+
"discovery grants require TLS and a client CA"
749+
));
750+
}
751+
740752
if !config.auth.allow_unauthenticated_users
753+
&& config.auth.discovery_grants_file.is_none()
741754
&& !config.mtls_auth.enabled
742755
&& !config.mtls_auth.operator_enabled
743756
&& config.oidc.is_none()

0 commit comments

Comments
 (0)