Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions crates/openshell-core/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -536,6 +536,9 @@ pub struct MtlsAuthConfig {
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct GatewayAuthConfig {
/// Operator-managed JSON discovery certificate grant file, reloaded on requests/watches.
#[serde(default)]
pub discovery_grants_file: Option<PathBuf>,
/// When true, unauthenticated user/CLI calls are accepted as a local
/// developer principal. This is an unsafe local-development escape hatch
/// for trusted, non-shared gateways. Sandbox supervisor calls still use
Expand Down
17 changes: 17 additions & 0 deletions crates/openshell-server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -273,3 +273,20 @@ return preferred `url`, configured `application_url`, and a gateway alternative
deployment origins are recomputed for durable replay without repeating the
mutation or changing resource identities. These are configured addresses, not
installation, ingress acceptance, or application health signals.

Discovery accepts an explicit workspace, up to 64 distinct `workspaces`, or the
existing all-workspaces selector. Omission and combined selectors fail.
Workspace users retain role/scope/membership authorization for every requested
workspace; all-workspaces access requires platform administration or a dedicated
certificate grant. Discovery certificates carry `OU=discovery`, use the configured
TLS client CA, and receive authority only through the operator-managed fingerprint
mapping at `gateway.auth.discovery_grants_file`. They never become user/operator
principals. Certificate expiry and grant removal are rechecked on active watches.

All-workspaces collection includes future active workspaces and omits terminating
ones, with a 64-workspace scan bound. Selected-workspace collection does not scan
unrelated workspaces. Combined snapshots retain the 4,096-route/4-MiB output bound
and ten-second collection deadline; each workspace scan retains its existing
1,000-record/4-MiB input bounds. Overflow and storage failures are errors, never
partial successful views. A workspace-set change during collection requires a
retry; this is not a cross-resource transactional snapshot.
7 changes: 5 additions & 2 deletions crates/openshell-server/src/auth/audit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -216,7 +216,8 @@ fn authenticated(principal: &Principal) -> bool {
match principal {
Principal::User(user) => user.identity.provider != IdentityProvider::LocalDev,
Principal::Anonymous => false,
Principal::Operator(_)
Principal::Discovery(_)
| Principal::Operator(_)
| Principal::Sandbox(_)
| Principal::Peer(_)
| Principal::Supervisor(_) => true,
Expand All @@ -226,7 +227,7 @@ fn authenticated(principal: &Principal) -> bool {
/// The mechanism that established `principal`, when it has an audit label.
fn mechanism(principal: &Principal) -> Option<AuthMechanism> {
match principal {
Principal::Operator(_) => Some(AuthMechanism::Mtls),
Principal::Discovery(_) | Principal::Operator(_) => Some(AuthMechanism::Mtls),
Principal::User(user) => match user.identity.provider {
IdentityProvider::Oidc => Some(AuthMechanism::Oidc),
IdentityProvider::Mtls => Some(AuthMechanism::Mtls),
Expand All @@ -253,6 +254,7 @@ fn credential_digest(
peer_certificate_sha256: Option<&str>,
) -> Option<[u8; 32]> {
let credential = match principal {
Principal::Discovery(reader) => reader.certificate_sha256.as_str(),
Principal::Operator(operator) => operator.certificate_sha256.as_str(),
Principal::User(user) if user.identity.provider == IdentityProvider::Mtls => {
peer_certificate_sha256.unwrap_or_default()
Expand Down Expand Up @@ -513,6 +515,7 @@ fn principal_user(principal: &Principal) -> User {
Principal::Supervisor(supervisor) => {
User::other(&supervisor.identity.subject, "Supervisor")
}
Principal::Discovery(reader) => User::other(&reader.certificate_sha256, "Discovery Reader"),
Principal::Anonymous => User::unidentified(),
}
}
Expand Down
192 changes: 192 additions & 0 deletions crates/openshell-server/src/auth/discovery.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

//! Operator-managed grants for verified discovery client certificates.

use super::{
identity::{Identity, IdentityProvider},
principal::DiscoveryPrincipal,
};
use serde::Deserialize;
use std::collections::BTreeSet;
use std::path::Path;
use tokio::io::AsyncReadExt as _;
use tonic::Status;

pub const MAX_WORKSPACES: usize = 64;
const MAX_FILE_BYTES: usize = 256 * 1024;

/// Certificate OU marks a restricted credential class, never an authority grant.
/// The separate operator-controlled fingerprint mapping supplies authority.
pub fn is_certificate(identity: &Identity) -> bool {
identity.provider == IdentityProvider::Mtls && identity.roles.iter().any(|r| r == "discovery")
}

pub fn is_method(path: &str) -> bool {
matches!(
path,
"/openshell.v1.OpenShell/GetServiceRouteSnapshot"
| "/openshell.v1.OpenShell/WatchServiceRoutes"
) && super::method_authz::lookup(path).is_some_and(|entry| {
entry.auth_mode == super::method_authz::AuthMode::Bearer
&& entry.scope.as_deref() == Some("sandbox:read")
&& entry.workspace_role.as_deref() == Some("user")
&& entry.global_role.is_none()
})
}

#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Grants {
readers: Vec<Grant>,
}

#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Grant {
pub certificate_sha256: String,
#[serde(default)]
pub workspaces: Vec<String>,
#[serde(default)]
pub all_workspaces: bool,
}

pub async fn grant(path: Option<&Path>, principal: &DiscoveryPrincipal) -> Result<Grant, Status> {
tokio::time::timeout(
std::time::Duration::from_secs(2),
load_grant(path, principal),
)
.await
.map_err(|_| Status::unavailable("discovery grant read timed out"))?
}

async fn load_grant(path: Option<&Path>, principal: &DiscoveryPrincipal) -> Result<Grant, Status> {
if principal.expires_at_ms <= crate::persistence::current_time_ms() {
return Err(Status::unauthenticated("discovery certificate expired"));
}
let path = path.ok_or_else(|| Status::permission_denied("discovery grant required"))?;
let file = tokio::fs::File::open(path)
.await
.map_err(|_| Status::unavailable("discovery grants unavailable"))?;
let mut bytes = Vec::new();
file.take(
u64::try_from(MAX_FILE_BYTES + 1).map_err(|_| Status::internal("invalid file limit"))?,
)
.read_to_end(&mut bytes)
.await
.map_err(|_| Status::unavailable("discovery grants unavailable"))?;
if bytes.len() > MAX_FILE_BYTES {
return Err(Status::resource_exhausted(
"discovery grants file too large",
));
}
let grants: Grants = serde_json::from_slice(&bytes)
.map_err(|_| Status::unavailable("invalid discovery grants"))?;
if grants.readers.len() > 1024 {
return Err(Status::resource_exhausted("too many discovery grants"));
}
let mut fingerprints = BTreeSet::new();
for reader in &grants.readers {
if reader.certificate_sha256.len() != 64
|| !reader
.certificate_sha256
.bytes()
.all(|c| c.is_ascii_digit() || (b'a'..=b'f').contains(&c))
|| !fingerprints.insert(&reader.certificate_sha256)
|| reader.workspaces.len() > MAX_WORKSPACES
|| reader.all_workspaces != reader.workspaces.is_empty()
{
return Err(Status::unavailable("invalid discovery grant mapping"));
}
let mut names = BTreeSet::new();
for workspace in &reader.workspaces {
crate::grpc::workspace::validate_workspace_name(workspace)
.map_err(|_| Status::unavailable("invalid discovery workspace grant"))?;
if !names.insert(workspace) {
return Err(Status::unavailable("duplicate discovery workspace grant"));
}
}
}
grants
.readers
.into_iter()
.find(|g| g.certificate_sha256 == principal.certificate_sha256)
.ok_or_else(|| Status::permission_denied("discovery grant revoked or missing"))
}

#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn fingerprint_grants_reload_and_never_derive_authority_from_subject_roles() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("grants.json");
let reader = DiscoveryPrincipal {
certificate_sha256: "a".repeat(64),
expires_at_ms: crate::persistence::current_time_ms() + 60_000,
};
let write = |readers: serde_json::Value| {
std::fs::write(
&path,
serde_json::to_vec(&serde_json::json!({"readers":readers})).unwrap(),
)
.unwrap();
};
write(
serde_json::json!([{"certificate_sha256":reader.certificate_sha256,"workspaces":["default"]}]),
);
assert_eq!(
grant(Some(&path), &reader).await.unwrap().workspaces,
vec!["default"]
);
let unrelated = DiscoveryPrincipal {
certificate_sha256: "b".repeat(64),
..reader.clone()
};
assert_eq!(
grant(Some(&path), &unrelated).await.err().unwrap().code(),
tonic::Code::PermissionDenied
);
write(serde_json::json!([]));
assert_eq!(
grant(Some(&path), &reader).await.err().unwrap().code(),
tonic::Code::PermissionDenied
);
write(
serde_json::json!([{"certificate_sha256":reader.certificate_sha256,"all_workspaces":true,"workspaces":["default"]}]),
);
assert_eq!(
grant(Some(&path), &reader).await.err().unwrap().code(),
tonic::Code::Unavailable
);
assert_eq!(
grant(
Some(&path),
&DiscoveryPrincipal {
expires_at_ms: 0,
..reader
}
)
.await
.err()
.unwrap()
.code(),
tonic::Code::Unauthenticated
);
}
#[test]
fn every_other_rpc_is_denied_including_unclassified_methods() {
for path in super::super::method_authz::all_paths() {
assert_eq!(
is_method(path),
matches!(
path,
"/openshell.v1.OpenShell/GetServiceRouteSnapshot"
| "/openshell.v1.OpenShell/WatchServiceRoutes"
),
"{path}"
);
}
assert!(!is_method("/openshell.v1.OpenShell/FutureReadMethod"));
}
}
5 changes: 3 additions & 2 deletions crates/openshell-server/src/auth/guard.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ use tonic::Status;
pub fn ensure_sandbox_scope(principal: &Principal, claimed_sandbox_id: &str) -> Result<(), Status> {
match principal {
Principal::User(_) | Principal::Operator(_) => Ok(()),
Principal::Peer(_) | Principal::Supervisor(_) => Err(deny(
Principal::Discovery(_) | Principal::Peer(_) | Principal::Supervisor(_) => Err(deny(
principal,
AuthzDenial::MethodNotPermitted,
"gateway peer and supervisor principals may not call sandbox-scoped methods",
Expand Down Expand Up @@ -88,7 +88,8 @@ pub fn ensure_sandbox_principal_scope(
ensure_sandbox_scope(principal, claimed_sandbox_id)?;
Ok(p.clone())
}
Principal::User(_)
Principal::Discovery(_)
| Principal::User(_)
| Principal::Operator(_)
| Principal::Peer(_)
| Principal::Supervisor(_) => Err(deny(
Expand Down
1 change: 1 addition & 0 deletions crates/openshell-server/src/auth/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ pub mod authenticator;
pub mod authz;
pub mod compute_driver;
pub mod descriptor_authz;
pub mod discovery;
pub mod extension_mint_limit;
pub mod guard;
mod http;
Expand Down
10 changes: 10 additions & 0 deletions crates/openshell-server/src/auth/principal.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ use openshell_core::jwt::CredentialEpoch;
/// retrieve it via `req.extensions().get::<Principal>()`.
#[derive(Debug, Clone)]
pub enum Principal {
/// Restricted verified certificate; authority is reloaded from an administrator grant.
Discovery(DiscoveryPrincipal),
/// Human caller authenticated via OIDC (Keycloak, Entra ID, Okta, etc.).
User(UserPrincipal),
/// Gateway-wide administrator authenticated only by a verified operator certificate.
Expand Down Expand Up @@ -59,6 +61,13 @@ pub struct OperatorPrincipal {
pub certificate_sha256: String,
}

/// Verified TLS discovery identity. No general user, role, or application authority.
#[derive(Debug, Clone)]
pub struct DiscoveryPrincipal {
pub certificate_sha256: String,
pub expires_at_ms: i64,
}

impl Principal {
/// Identity of a human/API caller, preserving its distinct principal variant.
pub fn user_identity(&self) -> Option<&Identity> {
Expand Down Expand Up @@ -148,6 +157,7 @@ impl Principal {
Self::Sandbox(sandbox) => format!("sandbox:{}", sandbox.sandbox_id),
Self::Peer(peer) => format!("peer:{}", peer.replica_id),
Self::Supervisor(supervisor) => format!("supervisor:{}", supervisor.identity.subject),
Self::Discovery(reader) => format!("discovery:{}", reader.certificate_sha256),
Self::Anonymous => "anonymous".to_string(),
}
}
Expand Down
9 changes: 9 additions & 0 deletions crates/openshell-server/src/auth/service_access.rs
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,15 @@ async fn authorize_with_chain(
workspace: &str,
chain: Option<AuthenticatorChain>,
) -> Result<Principal, Status> {
if peer
.identity
.as_ref()
.is_some_and(super::discovery::is_certificate)
{
return Err(Status::permission_denied(
"discovery certificates cannot access applications",
));
}
let origin = RequestOrigin::new(peer.source, headers);
let principal = authenticate(state, headers, peer, chain).await.map_err(
|(status, mechanism, reason)| {
Expand Down
4 changes: 2 additions & 2 deletions crates/openshell-server/src/auth/workspace_authz.rs
Original file line number Diff line number Diff line change
Expand Up @@ -207,7 +207,7 @@ pub async fn authorize_workspace(
workspace,
grant: AuthGrant::Sandbox,
}),
Principal::Peer(_) | Principal::Supervisor(_) => Err(deny(
Principal::Discovery(_) | Principal::Peer(_) | Principal::Supervisor(_) => Err(deny(
principal,
AuthzDenial::MethodNotPermitted,
"gateway peer and supervisor principals cannot perform workspace operations",
Expand Down Expand Up @@ -244,7 +244,7 @@ pub fn require_platform_admin(admin_role: &str, principal: &Principal) -> Result
}
Principal::User(_) => "platform admin role required for cross-workspace operations",
Principal::Sandbox(_) => "sandbox principals cannot perform cross-workspace operations",
Principal::Peer(_) | Principal::Supervisor(_) => {
Principal::Discovery(_) | Principal::Peer(_) | Principal::Supervisor(_) => {
"gateway peer and supervisor principals cannot perform cross-workspace operations"
}
Principal::Anonymous => return Err(Status::unauthenticated("authentication required")),
Expand Down
13 changes: 13 additions & 0 deletions crates/openshell-server/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -729,7 +729,20 @@ fn prepare_server_config_with_drivers(

/// Reject an implicit development gateway before starting any listeners or services.
fn validate_user_auth(config: &openshell_core::Config) -> Result<()> {
if config.auth.discovery_grants_file.is_some()
&& config
.tls
.as_ref()
.and_then(|tls| tls.client_ca_path.as_ref())
.is_none()
{
return Err(miette::miette!(
"discovery grants require TLS and a client CA"
));
}

if !config.auth.allow_unauthenticated_users
&& config.auth.discovery_grants_file.is_none()
&& !config.mtls_auth.enabled
&& !config.mtls_auth.operator_enabled
&& config.oidc.is_none()
Expand Down
8 changes: 5 additions & 3 deletions crates/openshell-server/src/grpc/policy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2788,9 +2788,11 @@ async fn resolve_sandbox_by_name_for_principal(
Principal::User(_) | Principal::Operator(_) => {
sandbox.ok_or_else(|| Status::not_found("sandbox not found"))
}
Principal::Peer(_) | Principal::Supervisor(_) => Err(Status::permission_denied(
"gateway peer and supervisor principals may not resolve sandbox configuration",
)),
Principal::Discovery(_) | Principal::Peer(_) | Principal::Supervisor(_) => {
Err(Status::permission_denied(
"gateway peer and supervisor principals may not resolve sandbox configuration",
))
}
Principal::Anonymous => Err(Status::unauthenticated(
"sandbox-scoped methods require an authenticated caller",
)),
Expand Down
3 changes: 2 additions & 1 deletion crates/openshell-server/src/grpc/provider.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4290,7 +4290,8 @@ async fn exchange_provider_subject_token_inner(
let auth_code = match request.extensions().get::<Principal>() {
Some(Principal::Sandbox(principal)) if principal.sandbox_id == req.sandbox_id => None,
Some(
Principal::Sandbox(_)
Principal::Discovery(_)
| Principal::Sandbox(_)
| Principal::User(_)
| Principal::Operator(_)
| Principal::Peer(_)
Expand Down
Loading
Loading