Skip to content

Commit 8d568c1

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 3b08d4c commit 8d568c1

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
@@ -474,6 +474,9 @@ pub struct MtlsAuthConfig {
474474
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
475475
#[serde(deny_unknown_fields)]
476476
pub struct GatewayAuthConfig {
477+
/// Operator-managed JSON discovery certificate grant file, reloaded on requests/watches.
478+
#[serde(default)]
479+
pub discovery_grants_file: Option<PathBuf>,
477480
/// When true, unauthenticated user/CLI calls are accepted as a local
478481
/// developer principal. This is an unsafe local-development escape hatch
479482
/// 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
@@ -273,3 +273,20 @@ return preferred `url`, configured `application_url`, and a gateway alternative
273273
deployment origins are recomputed for durable replay without repeating the
274274
mutation or changing resource identities. These are configured addresses, not
275275
installation, ingress acceptance, or application health signals.
276+
277+
Discovery accepts an explicit workspace, up to 64 distinct `workspaces`, or the
278+
existing all-workspaces selector. Omission and combined selectors fail.
279+
Workspace users retain role/scope/membership authorization for every requested
280+
workspace; all-workspaces access requires platform administration or a dedicated
281+
certificate grant. Discovery certificates carry `OU=discovery`, use the configured
282+
TLS client CA, and receive authority only through the operator-managed fingerprint
283+
mapping at `gateway.auth.discovery_grants_file`. They never become user/operator
284+
principals. Certificate expiry and grant removal are rechecked on active watches.
285+
286+
All-workspaces collection includes future active workspaces and omits terminating
287+
ones, with a 64-workspace scan bound. Selected-workspace collection does not scan
288+
unrelated workspaces. Combined snapshots retain the 4,096-route/4-MiB output bound
289+
and ten-second collection deadline; each workspace scan retains its existing
290+
1,000-record/4-MiB input bounds. Overflow and storage failures are errors, never
291+
partial successful views. A workspace-set change during collection requires a
292+
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
@@ -669,7 +669,20 @@ fn prepare_server_config_with_drivers(
669669

670670
/// Reject an implicit development gateway before starting any listeners or services.
671671
fn validate_user_auth(config: &openshell_core::Config) -> Result<()> {
672+
if config.auth.discovery_grants_file.is_some()
673+
&& config
674+
.tls
675+
.as_ref()
676+
.and_then(|tls| tls.client_ca_path.as_ref())
677+
.is_none()
678+
{
679+
return Err(miette::miette!(
680+
"discovery grants require TLS and a client CA"
681+
));
682+
}
683+
672684
if !config.auth.allow_unauthenticated_users
685+
&& config.auth.discovery_grants_file.is_none()
673686
&& !config.mtls_auth.enabled
674687
&& !config.mtls_auth.operator_enabled
675688
&& config.oidc.is_none()

0 commit comments

Comments
 (0)